diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..fa8a6d8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,71 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Client", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-client", + "program": "${workspaceFolder}/Client/bin/Debug/netcoreapp3.1/Client.dll", + "args": [], + "cwd": "${workspaceFolder}/Client", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://*:5002" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": "Launch IdentityServer", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-identity", + "program": "${workspaceFolder}/IdentityProvider/bin/Debug/netcoreapp3.1/IdentityProvider.dll", + "args": [], + "cwd": "${workspaceFolder}/IdentityProvider", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://*:5000" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": "Launch API", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-api", + "program": "${workspaceFolder}/API/bin/Debug/netcoreapp3.1/API.dll", + "args": [], + "cwd": "${workspaceFolder}/API", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://*:5001" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..bdfc229 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,113 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-client", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Client/Client.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish-client", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/Client/Client.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch-client", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/Client/Client.csproj" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "build-identity", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/IdentityProvider/IdentityProvider.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish-identity", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/IdentityProvider/IdentityProvider.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch-identity", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/IdentityProvider/IdentityProvider.csproj" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "build-api", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/API/API.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish-api", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/API/API.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch-api", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/API/API.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/API/API.csproj b/API/API.csproj index 0e7d356..aa45b21 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net6.0 diff --git a/API/Controllers/WeatherForecastController.cs b/API/Controllers/WeatherForecastController.cs index 3ee9095..f019c9b 100644 --- a/API/Controllers/WeatherForecastController.cs +++ b/API/Controllers/WeatherForecastController.cs @@ -1,32 +1,26 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +namespace API.Controllers; -namespace API.Controllers +[Authorize] +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase { - [Authorize] - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase + private static readonly string[] Summaries = { - private static readonly string[] Summaries = - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; - [HttpGet] - public IEnumerable Get() - { - var rng = new Random(); + [HttpGet] + public IEnumerable Get() + { + var rng = new Random(); - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateTime.Now.AddDays(index), - TemperatureC = rng.Next(-20, 55), - Summary = Summaries[rng.Next(Summaries.Length)] - }).ToArray(); - } + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateTime.Now.AddDays(index), + TemperatureC = rng.Next(-20, 55), + Summary = Summaries[rng.Next(Summaries.Length)] + }).ToArray(); } } + diff --git a/API/Program.cs b/API/Program.cs index cc575f7..3c9425d 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,20 +1,10 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; -namespace API -{ - 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(); - }); - } -} +CreateHostBuilder(args).Build().Run(); + +static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); diff --git a/API/Startup.cs b/API/Startup.cs index 0c9e1b8..170b9c0 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -1,33 +1,30 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; +namespace API; -namespace API +public class Startup { - public class Startup + public void ConfigureServices(IServiceCollection services) { - public void ConfigureServices(IServiceCollection services) - { - services.AddControllers(); + services.AddControllers(); - services.AddAuthentication("Bearer") - .AddJwtBearer("Bearer", options => - { - options.Audience = "api1"; - options.Authority = "https://localhost:5000"; - }); - } + services.AddAuthentication("Bearer") + .AddJwtBearer("Bearer", options => + { + options.Audience = "api1"; + options.Authority = "https://localhost:5000"; + }); + } - public void Configure(IApplicationBuilder app) - { - app.UseDeveloperExceptionPage(); - app.UseHttpsRedirection(); + public void Configure(IApplicationBuilder app) + { + app.UseDeveloperExceptionPage(); + app.UseHttpsRedirection(); - app.UseRouting(); + app.UseRouting(); - app.UseAuthentication(); - app.UseAuthorization(); + app.UseAuthentication(); + app.UseAuthorization(); - app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); - } + app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); } } + diff --git a/API/Usings.cs b/API/Usings.cs new file mode 100644 index 0000000..7d2868f --- /dev/null +++ b/API/Usings.cs @@ -0,0 +1,10 @@ +global using System; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.Extensions.DependencyInjection; +global using API; +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.Extensions.Hosting; +global using System.Collections.Generic; +global using System.Linq; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Mvc; \ No newline at end of file diff --git a/API/WeatherForecast.cs b/API/WeatherForecast.cs index 6f8943b..e4ed57b 100644 --- a/API/WeatherForecast.cs +++ b/API/WeatherForecast.cs @@ -1,5 +1,3 @@ -using System; - namespace API { public class WeatherForecast diff --git a/Client/Client.csproj b/Client/Client.csproj index 90be72c..e6b06ad 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net6.0 diff --git a/Client/Controllers/HomeController.cs b/Client/Controllers/HomeController.cs index 7c6ec6e..7d79a9f 100644 --- a/Client/Controllers/HomeController.cs +++ b/Client/Controllers/HomeController.cs @@ -1,37 +1,28 @@ -using System; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +namespace Client.Controllers; -namespace Client.Controllers +public class HomeController : Controller { - public class HomeController : Controller + public IActionResult Index() { - public IActionResult Index() - { - return View(); - } + return View(); + } - [Authorize] - public IActionResult Privacy() => View(); + [Authorize] + public IActionResult Privacy() => View(); - [Authorize] - [HttpGet("/call-api")] - public async Task CallApi() - { - var accessToken = await HttpContext.GetTokenAsync("access_token"); - if (accessToken == null) throw new InvalidOperationException("Could not find access token"); - - var client = new HttpClient(); // you shouldn't do this. Instead use IHttpClientFactory - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + [Authorize] + [HttpGet("/call-api")] + public async Task CallApi() + { + var accessToken = await HttpContext.GetTokenAsync("access_token"); + if (accessToken == null) throw new InvalidOperationException("Could not find access token"); + + var client = new HttpClient(); // you shouldn't do this. Instead use IHttpClientFactory + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - var response = await client.GetAsync("https://localhost:5001/weatherforecast"); + var response = await client.GetAsync("https://localhost:5001/weatherforecast"); - return Ok(response.IsSuccessStatusCode - ? "API access authorized!" : $"API access failed. Status code: {response.StatusCode}"); - } + return Ok(response.IsSuccessStatusCode + ? "API access authorized!" : $"API access failed. Status code: {response.StatusCode}"); } } diff --git a/Client/Program.cs b/Client/Program.cs index 8561404..1230cfe 100644 --- a/Client/Program.cs +++ b/Client/Program.cs @@ -1,20 +1,9 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +CreateHostBuilder(args).Build().Run(); -namespace Client -{ - public class Program - { - public static void Main(string[] args) +static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => { - CreateHostBuilder(args).Build().Run(); - } + webBuilder.UseStartup(); + }); - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } -} diff --git a/Client/Startup.cs b/Client/Startup.cs index 1d20eb1..ba11990 100644 --- a/Client/Startup.cs +++ b/Client/Startup.cs @@ -1,52 +1,50 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -namespace Client +namespace Client; +public class Startup { - public class Startup + + public void ConfigureServices(IServiceCollection services) + { + services.AddControllersWithViews(); + + IdentityModelEventSource.ShowPII = true; + + services.AddAuthentication(options => + { + options.DefaultScheme = "cookie"; + options.DefaultChallengeScheme = "oidc"; + }) + .AddCookie("cookie") + .AddOpenIdConnect("oidc", options => + { + options.Authority = "https://localhost:5000"; + options.ClientId = "oidcClient"; + options.ClientSecret = "SuperSecretPassword"; + + options.ResponseType = "code"; + options.UsePkce = true; + options.ResponseMode = "query"; + + // options.CallbackPath = "/signin-oidc"; // default redirect URI + + // options.Scope.Add("oidc"); // default scope + // options.Scope.Add("profile"); // default scope + options.Scope.Add("api1.read"); + options.SaveTokens = true; + }); + } + + public void Configure(IApplicationBuilder app) { + app.UseDeveloperExceptionPage(); + app.UseHttpsRedirection(); + + app.UseStaticFiles(); + app.UseRouting(); - public void ConfigureServices(IServiceCollection services) - { - services.AddControllersWithViews(); - - services.AddAuthentication(options => - { - options.DefaultScheme = "cookie"; - options.DefaultChallengeScheme = "oidc"; - }) - .AddCookie("cookie") - .AddOpenIdConnect("oidc", options => - { - options.Authority = "https://localhost:5000"; - options.ClientId = "oidcClient"; - options.ClientSecret = "SuperSecretPassword"; - - options.ResponseType = "code"; - options.UsePkce = true; - options.ResponseMode = "query"; - - // options.CallbackPath = "/signin-oidc"; // default redirect URI - - // options.Scope.Add("oidc"); // default scope - // options.Scope.Add("profile"); // default scope - options.Scope.Add("api1.read"); - options.SaveTokens = true; - }); - } - - public void Configure(IApplicationBuilder app) - { - app.UseDeveloperExceptionPage(); - app.UseHttpsRedirection(); - - app.UseStaticFiles(); - app.UseRouting(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); - } + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); } } + diff --git a/Client/Usings.cs b/Client/Usings.cs new file mode 100644 index 0000000..78572de --- /dev/null +++ b/Client/Usings.cs @@ -0,0 +1,13 @@ +global using Client; +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.Extensions.Hosting; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.IdentityModel.Logging; +global using System; +global using System.Net.Http; +global using System.Net.Http.Headers; +global using System.Threading.Tasks; +global using Microsoft.AspNetCore.Authentication; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Mvc; \ No newline at end of file diff --git a/IdentityProvider/ApplicationDbContext.cs b/IdentityProvider/ApplicationDbContext.cs index 8a05008..c3b7c94 100644 --- a/IdentityProvider/ApplicationDbContext.cs +++ b/IdentityProvider/ApplicationDbContext.cs @@ -1,11 +1,6 @@ -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; +namespace IdentityProvider; -namespace IdentityProvider +public class ApplicationDbContext : IdentityDbContext { - public class ApplicationDbContext : IdentityDbContext - { - public ApplicationDbContext(DbContextOptions options) : base(options) { } - } - + public ApplicationDbContext(DbContextOptions options) : base(options) { } } \ No newline at end of file diff --git a/IdentityProvider/Config.cs b/IdentityProvider/Config.cs index a0cb86d..aaa03f5 100644 --- a/IdentityProvider/Config.cs +++ b/IdentityProvider/Config.cs @@ -1,10 +1,3 @@ -using System.Collections.Generic; -using System.Security.Claims; -using Duende.IdentityServer; -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Test; -using IdentityModel; - namespace IdentityProvider { internal class Clients diff --git a/IdentityProvider/IdentityProvider.csproj b/IdentityProvider/IdentityProvider.csproj index 97222e6..53cd3e5 100644 --- a/IdentityProvider/IdentityProvider.csproj +++ b/IdentityProvider/IdentityProvider.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net6.0 diff --git a/IdentityProvider/Program.cs b/IdentityProvider/Program.cs index 415a9f9..4e1c6fb 100644 --- a/IdentityProvider/Program.cs +++ b/IdentityProvider/Program.cs @@ -1,20 +1,8 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +CreateHostBuilder(args).Build().Run(); -namespace IdentityProvider -{ - public class Program - { - public static void Main(string[] args) +static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } -} + webBuilder.UseStartup(); + }); \ No newline at end of file diff --git a/IdentityProvider/Quickstart/Account/AccountController.cs b/IdentityProvider/Quickstart/Account/AccountController.cs index 159d867..684a91b 100644 --- a/IdentityProvider/Quickstart/Account/AccountController.cs +++ b/IdentityProvider/Quickstart/Account/AccountController.cs @@ -1,98 +1,128 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using IdentityModel; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Linq; -using System.Threading.Tasks; -using Duende.IdentityServer; -using Duende.IdentityServer.Events; -using Duende.IdentityServer.Extensions; -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Services; -using Duende.IdentityServer.Stores; -using Microsoft.AspNetCore.Identity; -using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; - -namespace IdentityServerHost.Quickstart.UI +namespace IdentityServerHost.Quickstart.UI; + +/// +/// This sample controller implements a typical login/logout/provision workflow for local and external accounts. +/// The login service encapsulates the interactions with the user data store. This data store is in-memory only and cannot be used for production! +/// The interaction service provides a way for the UI to communicate with identityserver for validation and context retrieval +/// +[SecurityHeaders] +[AllowAnonymous] +public class AccountController : Controller { + private readonly IIdentityServerInteractionService _interaction; + private readonly IClientStore _clientStore; + private readonly IAuthenticationSchemeProvider _schemeProvider; + private readonly IIdentityProviderStore _identityProviderStore; + private readonly IEventService _events; + private readonly SignInManager _signInManager; + + public AccountController( + IIdentityServerInteractionService interaction, + IClientStore clientStore, + IAuthenticationSchemeProvider schemeProvider, + IIdentityProviderStore identityProviderStore, + IEventService events, + SignInManager signInManager) + { + _interaction = interaction; + _clientStore = clientStore; + _schemeProvider = schemeProvider; + _identityProviderStore = identityProviderStore; + _events = events; + + _signInManager = signInManager; + } + /// - /// This sample controller implements a typical login/logout/provision workflow for local and external accounts. - /// The login service encapsulates the interactions with the user data store. This data store is in-memory only and cannot be used for production! - /// The interaction service provides a way for the UI to communicate with identityserver for validation and context retrieval + /// Entry point into the login workflow /// - [SecurityHeaders] - [AllowAnonymous] - public class AccountController : Controller + [HttpGet] + public async Task Login(string returnUrl) { - private readonly IIdentityServerInteractionService _interaction; - private readonly IClientStore _clientStore; - private readonly IAuthenticationSchemeProvider _schemeProvider; - private readonly IIdentityProviderStore _identityProviderStore; - private readonly IEventService _events; - private readonly SignInManager _signInManager; - - public AccountController( - IIdentityServerInteractionService interaction, - IClientStore clientStore, - IAuthenticationSchemeProvider schemeProvider, - IIdentityProviderStore identityProviderStore, - IEventService events, - SignInManager signInManager) - { - _interaction = interaction; - _clientStore = clientStore; - _schemeProvider = schemeProvider; - _identityProviderStore = identityProviderStore; - _events = events; + // build a model so we know what to show on the login page + var vm = await BuildLoginViewModelAsync(returnUrl); - _signInManager = signInManager; + if (vm.IsExternalLoginOnly) + { + // we only have one option for logging in and it's an external provider + return RedirectToAction("Challenge", "External", new { scheme = vm.ExternalLoginScheme, returnUrl }); } - /// - /// Entry point into the login workflow - /// - [HttpGet] - public async Task Login(string returnUrl) + return View(vm); + } + + /// + /// Handle postback from username/password login + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Login(LoginInputModel model, string button) + { + // check if we are in the context of an authorization request + var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); + + // the user clicked the "cancel" button + if (button != "login") { - // build a model so we know what to show on the login page - var vm = await BuildLoginViewModelAsync(returnUrl); + if (context != null) + { + // if the user cancels, send a result back into IdentityServer as if they + // denied the consent (even if this client does not require consent). + // this will send back an access denied OIDC error response to the client. + await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied); + + // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null + if (context.IsNativeClient()) + { + // The client is native, so this change in how to + // return the response is for better UX for the end user. + return this.LoadingPage("Redirect", model.ReturnUrl); + } - if (vm.IsExternalLoginOnly) + return Redirect(model.ReturnUrl); + } + else { - // we only have one option for logging in and it's an external provider - return RedirectToAction("Challenge", "External", new { scheme = vm.ExternalLoginScheme, returnUrl }); + // since we don't have a valid context, then we just go back to the home page + return Redirect("~/"); } - - return View(vm); } - /// - /// Handle postback from username/password login - /// - [HttpPost] - [ValidateAntiForgeryToken] - public async Task Login(LoginInputModel model, string button) + if (ModelState.IsValid) { - // check if we are in the context of an authorization request - var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); + // find user by username + var user = await _signInManager.UserManager.FindByNameAsync(model.Username); - // the user clicked the "cancel" button - if (button != "login") + // validate username/password using ASP.NET Identity + if (user != null && (await _signInManager.CheckPasswordSignInAsync(user, model.Password, true)) == SignInResult.Success) { - if (context != null) + await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId)); + + // only set explicit expiration here if user chooses "remember me". + // otherwise we rely upon expiration configured in cookie middleware. + AuthenticationProperties props = null; + if (AccountOptions.AllowRememberLogin && model.RememberLogin) { - // if the user cancels, send a result back into IdentityServer as if they - // denied the consent (even if this client does not require consent). - // this will send back an access denied OIDC error response to the client. - await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied); + props = new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration) + }; + }; - // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null + // issue authentication cookie with subject ID and username + var isuser = new IdentityServerUser(user.Id) + { + DisplayName = user.UserName + }; + + await HttpContext.SignInAsync(isuser, props); + + if (context != null) + { if (context.IsNativeClient()) { // The client is native, so this change in how to @@ -100,282 +130,233 @@ public async Task Login(LoginInputModel model, string button) return this.LoadingPage("Redirect", model.ReturnUrl); } + // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null return Redirect(model.ReturnUrl); } - else + + // request for a local page + if (Url.IsLocalUrl(model.ReturnUrl)) + { + return Redirect(model.ReturnUrl); + } + else if (string.IsNullOrEmpty(model.ReturnUrl)) { - // since we don't have a valid context, then we just go back to the home page return Redirect("~/"); } + else + { + // user might have clicked on a malicious link - should be logged + throw new Exception("invalid return URL"); + } } - if (ModelState.IsValid) - { - // find user by username - var user = await _signInManager.UserManager.FindByNameAsync(model.Username); - - // validate username/password using ASP.NET Identity - if (user != null && (await _signInManager.CheckPasswordSignInAsync(user, model.Password, true)) == SignInResult.Success) - { - await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId)); + await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials", clientId:context?.Client.ClientId)); + ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage); + } - // only set explicit expiration here if user chooses "remember me". - // otherwise we rely upon expiration configured in cookie middleware. - AuthenticationProperties props = null; - if (AccountOptions.AllowRememberLogin && model.RememberLogin) - { - props = new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration) - }; - }; + // something went wrong, show form with error + var vm = await BuildLoginViewModelAsync(model); + return View(vm); + } - // issue authentication cookie with subject ID and username - var isuser = new IdentityServerUser(user.Id) - { - DisplayName = user.UserName - }; + + /// + /// Show logout page + /// + [HttpGet] + public async Task Logout(string logoutId) + { + // build a model so the logout page knows what to display + var vm = await BuildLogoutViewModelAsync(logoutId); - await HttpContext.SignInAsync(isuser, props); + if (vm.ShowLogoutPrompt == false) + { + // if the request for logout was properly authenticated from IdentityServer, then + // we don't need to show the prompt and can just log the user out directly. + return await Logout(vm); + } - if (context != null) - { - if (context.IsNativeClient()) - { - // The client is native, so this change in how to - // return the response is for better UX for the end user. - return this.LoadingPage("Redirect", model.ReturnUrl); - } - - // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null - return Redirect(model.ReturnUrl); - } + return View(vm); + } - // request for a local page - if (Url.IsLocalUrl(model.ReturnUrl)) - { - return Redirect(model.ReturnUrl); - } - else if (string.IsNullOrEmpty(model.ReturnUrl)) - { - return Redirect("~/"); - } - else - { - // user might have clicked on a malicious link - should be logged - throw new Exception("invalid return URL"); - } - } + /// + /// Handle logout page postback + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Logout(LogoutInputModel model) + { + // build a model so the logged out page knows what to display + var vm = await BuildLoggedOutViewModelAsync(model.LogoutId); - await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials", clientId:context?.Client.ClientId)); - ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage); - } + if (User?.Identity.IsAuthenticated == true) + { + // delete local authentication cookie + await HttpContext.SignOutAsync(); - // something went wrong, show form with error - var vm = await BuildLoginViewModelAsync(model); - return View(vm); + // raise the logout event + await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); } - - /// - /// Show logout page - /// - [HttpGet] - public async Task Logout(string logoutId) + // check if we need to trigger sign-out at an upstream identity provider + if (vm.TriggerExternalSignout) { - // build a model so the logout page knows what to display - var vm = await BuildLogoutViewModelAsync(logoutId); + // 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. + string url = Url.Action("Logout", new { logoutId = vm.LogoutId }); - if (vm.ShowLogoutPrompt == false) - { - // if the request for logout was properly authenticated from IdentityServer, then - // we don't need to show the prompt and can just log the user out directly. - return await Logout(vm); - } - - return View(vm); + // this triggers a redirect to the external provider for sign-out + return SignOut(new AuthenticationProperties { RedirectUri = url }, vm.ExternalAuthenticationScheme); } - /// - /// Handle logout page postback - /// - [HttpPost] - [ValidateAntiForgeryToken] - public async Task Logout(LogoutInputModel model) - { - // build a model so the logged out page knows what to display - var vm = await BuildLoggedOutViewModelAsync(model.LogoutId); + return View("LoggedOut", vm); + } - if (User?.Identity.IsAuthenticated == true) - { - // delete local authentication cookie - await HttpContext.SignOutAsync(); + [HttpGet] + public IActionResult AccessDenied() + { + return View(); + } - // 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) + /*****************************************/ + /* helper APIs for the AccountController */ + /*****************************************/ + private async Task BuildLoginViewModelAsync(string returnUrl) + { + var context = await _interaction.GetAuthorizationContextAsync(returnUrl); + if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null) + { + var local = context.IdP == Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider; + + // this is meant to short circuit the UI and only trigger the one external IdP + var vm = new LoginViewModel { - // 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. - string url = Url.Action("Logout", new { logoutId = vm.LogoutId }); + EnableLocalLogin = local, + ReturnUrl = returnUrl, + Username = context?.LoginHint, + }; - // this triggers a redirect to the external provider for sign-out - return SignOut(new AuthenticationProperties { RedirectUri = url }, vm.ExternalAuthenticationScheme); + if (!local) + { + vm.ExternalProviders = new[] { new ExternalProvider { AuthenticationScheme = context.IdP } }; } - return View("LoggedOut", vm); + return vm; } - [HttpGet] - public IActionResult AccessDenied() - { - return View(); - } + var schemes = await _schemeProvider.GetAllSchemesAsync(); + var providers = schemes + .Where(x => x.DisplayName != null) + .Select(x => new ExternalProvider + { + DisplayName = x.DisplayName ?? x.Name, + AuthenticationScheme = x.Name + }).ToList(); - /*****************************************/ - /* helper APIs for the AccountController */ - /*****************************************/ - private async Task BuildLoginViewModelAsync(string returnUrl) - { - var context = await _interaction.GetAuthorizationContextAsync(returnUrl); - if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null) + var dyanmicSchemes = (await _identityProviderStore.GetAllSchemeNamesAsync()) + .Where(x => x.Enabled) + .Select(x => new ExternalProvider { - var local = context.IdP == Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider; + AuthenticationScheme = x.Scheme, + DisplayName = x.DisplayName + }); + providers.AddRange(dyanmicSchemes); - // this is meant to short circuit the UI and only trigger the one external IdP - var vm = new LoginViewModel - { - EnableLocalLogin = local, - ReturnUrl = returnUrl, - Username = context?.LoginHint, - }; + var allowLocal = true; + if (context?.Client.ClientId != null) + { + var client = await _clientStore.FindEnabledClientByIdAsync(context.Client.ClientId); + if (client != null) + { + allowLocal = client.EnableLocalLogin; - if (!local) + if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Any()) { - vm.ExternalProviders = new[] { new ExternalProvider { AuthenticationScheme = context.IdP } }; + providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList(); } - - return vm; } + } - var schemes = await _schemeProvider.GetAllSchemesAsync(); - - var providers = schemes - .Where(x => x.DisplayName != null) - .Select(x => new ExternalProvider - { - DisplayName = x.DisplayName ?? x.Name, - AuthenticationScheme = x.Name - }).ToList(); - - var dyanmicSchemes = (await _identityProviderStore.GetAllSchemeNamesAsync()) - .Where(x => x.Enabled) - .Select(x => new ExternalProvider - { - AuthenticationScheme = x.Scheme, - DisplayName = x.DisplayName - }); - providers.AddRange(dyanmicSchemes); - - var allowLocal = true; - if (context?.Client.ClientId != null) - { - var client = await _clientStore.FindEnabledClientByIdAsync(context.Client.ClientId); - if (client != null) - { - allowLocal = client.EnableLocalLogin; + return new LoginViewModel + { + AllowRememberLogin = AccountOptions.AllowRememberLogin, + EnableLocalLogin = allowLocal && AccountOptions.AllowLocalLogin, + ReturnUrl = returnUrl, + Username = context?.LoginHint, + ExternalProviders = providers.ToArray() + }; + } - if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Any()) - { - providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList(); - } - } - } + private async Task BuildLoginViewModelAsync(LoginInputModel model) + { + var vm = await BuildLoginViewModelAsync(model.ReturnUrl); + vm.Username = model.Username; + vm.RememberLogin = model.RememberLogin; + return vm; + } - return new LoginViewModel - { - AllowRememberLogin = AccountOptions.AllowRememberLogin, - EnableLocalLogin = allowLocal && AccountOptions.AllowLocalLogin, - ReturnUrl = returnUrl, - Username = context?.LoginHint, - ExternalProviders = providers.ToArray() - }; - } + private async Task BuildLogoutViewModelAsync(string logoutId) + { + var vm = new LogoutViewModel { LogoutId = logoutId, ShowLogoutPrompt = AccountOptions.ShowLogoutPrompt }; - private async Task BuildLoginViewModelAsync(LoginInputModel model) + if (User?.Identity.IsAuthenticated != true) { - var vm = await BuildLoginViewModelAsync(model.ReturnUrl); - vm.Username = model.Username; - vm.RememberLogin = model.RememberLogin; + // if the user is not authenticated, then just show logged out page + vm.ShowLogoutPrompt = false; return vm; } - private async Task BuildLogoutViewModelAsync(string logoutId) + var context = await _interaction.GetLogoutContextAsync(logoutId); + if (context?.ShowSignoutPrompt == false) { - var vm = new LogoutViewModel { LogoutId = logoutId, ShowLogoutPrompt = AccountOptions.ShowLogoutPrompt }; - - if (User?.Identity.IsAuthenticated != true) - { - // if the user is not authenticated, then just show logged out page - vm.ShowLogoutPrompt = false; - return vm; - } - - var context = await _interaction.GetLogoutContextAsync(logoutId); - if (context?.ShowSignoutPrompt == false) - { - // it's safe to automatically sign-out - vm.ShowLogoutPrompt = false; - return vm; - } - - // show the logout prompt. this prevents attacks where the user - // is automatically signed out by another malicious web page. + // it's safe to automatically sign-out + vm.ShowLogoutPrompt = false; return vm; } - private async Task BuildLoggedOutViewModelAsync(string logoutId) - { - // get context information (client name, post logout redirect URI and iframe for federated signout) - var logout = await _interaction.GetLogoutContextAsync(logoutId); + // show the logout prompt. this prevents attacks where the user + // is automatically signed out by another malicious web page. + return vm; + } - var vm = new LoggedOutViewModel - { - AutomaticRedirectAfterSignOut = AccountOptions.AutomaticRedirectAfterSignOut, - PostLogoutRedirectUri = logout?.PostLogoutRedirectUri, - ClientName = string.IsNullOrEmpty(logout?.ClientName) ? logout?.ClientId : logout?.ClientName, - SignOutIframeUrl = logout?.SignOutIFrameUrl, - LogoutId = logoutId - }; + private async Task BuildLoggedOutViewModelAsync(string logoutId) + { + // get context information (client name, post logout redirect URI and iframe for federated signout) + var logout = await _interaction.GetLogoutContextAsync(logoutId); - if (User?.Identity.IsAuthenticated == true) + var vm = new LoggedOutViewModel + { + AutomaticRedirectAfterSignOut = AccountOptions.AutomaticRedirectAfterSignOut, + PostLogoutRedirectUri = logout?.PostLogoutRedirectUri, + ClientName = string.IsNullOrEmpty(logout?.ClientName) ? logout?.ClientId : logout?.ClientName, + SignOutIframeUrl = logout?.SignOutIFrameUrl, + LogoutId = logoutId + }; + + if (User?.Identity.IsAuthenticated == true) + { + var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; + if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) { - var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; - if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) + var providerSupportsSignout = await HttpContext.GetSchemeSupportsSignOutAsync(idp); + if (providerSupportsSignout) { - var providerSupportsSignout = await HttpContext.GetSchemeSupportsSignOutAsync(idp); - if (providerSupportsSignout) + if (vm.LogoutId == null) { - if (vm.LogoutId == null) - { - // if there's no current logout context, we need to create one - // this captures necessary info from the current logged in user - // before we signout and redirect away to the external IdP for signout - vm.LogoutId = await _interaction.CreateLogoutContextAsync(); - } - - vm.ExternalAuthenticationScheme = idp; + // if there's no current logout context, we need to create one + // this captures necessary info from the current logged in user + // before we signout and redirect away to the external IdP for signout + vm.LogoutId = await _interaction.CreateLogoutContextAsync(); } + + vm.ExternalAuthenticationScheme = idp; } } - - return vm; } + + return vm; } } diff --git a/IdentityProvider/Quickstart/Account/AccountOptions.cs b/IdentityProvider/Quickstart/Account/AccountOptions.cs index 4997d18..4c85763 100644 --- a/IdentityProvider/Quickstart/Account/AccountOptions.cs +++ b/IdentityProvider/Quickstart/Account/AccountOptions.cs @@ -1,20 +1,17 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System; - -namespace IdentityServerHost.Quickstart.UI +public class AccountOptions { - public class AccountOptions - { - public static bool AllowLocalLogin = true; - public static bool AllowRememberLogin = true; - public static TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30); + public static bool AllowLocalLogin = true; + public static bool AllowRememberLogin = true; + public static TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30); - public static bool ShowLogoutPrompt = true; - public static bool AutomaticRedirectAfterSignOut = false; + public static bool ShowLogoutPrompt = true; + public static bool AutomaticRedirectAfterSignOut = false; - public static string InvalidCredentialsErrorMessage = "Invalid username or password"; - } + public static string InvalidCredentialsErrorMessage = "Invalid username or password"; } + diff --git a/IdentityProvider/Quickstart/Account/ExternalController.cs b/IdentityProvider/Quickstart/Account/ExternalController.cs index c5f97ce..522b8c9 100644 --- a/IdentityProvider/Quickstart/Account/ExternalController.cs +++ b/IdentityProvider/Quickstart/Account/ExternalController.cs @@ -1,203 +1,183 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using IdentityModel; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Claims; -using System.Threading.Tasks; -using Duende.IdentityServer; -using Duende.IdentityServer.Events; -using Duende.IdentityServer.Services; -using Duende.IdentityServer.Stores; -using Duende.IdentityServer.Test; -using Microsoft.AspNetCore.Identity; - -namespace IdentityServerHost.Quickstart.UI +[SecurityHeaders] +[AllowAnonymous] +public class ExternalController : Controller { - [SecurityHeaders] - [AllowAnonymous] - public class ExternalController : Controller + private readonly IIdentityServerInteractionService _interaction; + private readonly IClientStore _clientStore; + private readonly ILogger _logger; + private readonly IEventService _events; + private readonly UserManager _userManager; + + public ExternalController( + IIdentityServerInteractionService interaction, + IClientStore clientStore, + IEventService events, + ILogger logger, + UserManager userManager) { - private readonly IIdentityServerInteractionService _interaction; - private readonly IClientStore _clientStore; - private readonly ILogger _logger; - private readonly IEventService _events; - private readonly UserManager _userManager; - - public ExternalController( - IIdentityServerInteractionService interaction, - IClientStore clientStore, - IEventService events, - ILogger logger, - UserManager userManager) - { - _interaction = interaction; - _clientStore = clientStore; - _logger = logger; - _events = events; + _interaction = interaction; + _clientStore = clientStore; + _logger = logger; + _events = events; - _userManager = userManager; - } + _userManager = userManager; + } - /// - /// initiate roundtrip to external authentication provider - /// - [HttpGet] - public IActionResult Challenge(string scheme, string returnUrl) - { - if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/"; + /// + /// initiate roundtrip to external authentication provider + /// + [HttpGet] + public IActionResult Challenge(string scheme, string returnUrl) + { + if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/"; - // validate returnUrl - either it is a valid OIDC URL or back to a local page - if (Url.IsLocalUrl(returnUrl) == false && _interaction.IsValidReturnUrl(returnUrl) == false) - { - // user might have clicked on a malicious link - should be logged - throw new Exception("invalid return URL"); - } - - // start challenge and roundtrip the return URL and scheme - var props = new AuthenticationProperties - { - RedirectUri = Url.Action(nameof(Callback)), - Items = - { - { "returnUrl", returnUrl }, - { "scheme", scheme }, - } - }; - - return Challenge(props, scheme); + // validate returnUrl - either it is a valid OIDC URL or back to a local page + if (Url.IsLocalUrl(returnUrl) == false && _interaction.IsValidReturnUrl(returnUrl) == false) + { + // user might have clicked on a malicious link - should be logged + throw new Exception("invalid return URL"); } - - /// - /// Post processing of external authentication - /// - [HttpGet] - public async Task Callback() + + // start challenge and roundtrip the return URL and scheme + var props = new AuthenticationProperties { - // read external identity from the temporary cookie - var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); - if (result?.Succeeded != true) + RedirectUri = Url.Action(nameof(Callback)), + Items = { - throw new Exception("External authentication error"); + { "returnUrl", returnUrl }, + { "scheme", scheme }, } + }; - if (_logger.IsEnabled(LogLevel.Debug)) - { - var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}"); - _logger.LogDebug("External claims: {@claims}", externalClaims); - } + return Challenge(props, scheme); + } - // lookup our user and external provider info - var (user, provider, providerUserId, claims) = await FindUserFromExternalProvider(result); - if (user == null) - { - // this might be where you might initiate a custom workflow for user registration - // in this sample we don't show how that would be done, as our sample implementation - // simply auto-provisions new external user - user = await AutoProvisionUser(provider, providerUserId, claims); - } + /// + /// Post processing of external authentication + /// + [HttpGet] + public async Task Callback() + { + // read external identity from the temporary cookie + var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); + if (result?.Succeeded != true) + { + throw new Exception("External authentication error"); + } - // this allows us to collect any additional claims or properties - // for the specific protocols used and store them in the local auth cookie. - // this is typically used to store data needed for signout from those protocols. - var additionalLocalClaims = new List(); - var localSignInProps = new AuthenticationProperties(); - ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); - - // issue authentication cookie for user - var isuser = new IdentityServerUser(user.Id) - { - DisplayName = user.UserName, - IdentityProvider = provider, - AdditionalClaims = additionalLocalClaims - }; + if (_logger.IsEnabled(LogLevel.Debug)) + { + var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}"); + _logger.LogDebug("External claims: {@claims}", externalClaims); + } - await HttpContext.SignInAsync(isuser, localSignInProps); + // lookup our user and external provider info + var (user, provider, providerUserId, claims) = await FindUserFromExternalProvider(result); + if (user == null) + { + // this might be where you might initiate a custom workflow for user registration + // in this sample we don't show how that would be done, as our sample implementation + // simply auto-provisions new external user + user = await AutoProvisionUser(provider, providerUserId, claims); + } - // delete temporary cookie used during external authentication - await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); + // this allows us to collect any additional claims or properties + // for the specific protocols used and store them in the local auth cookie. + // this is typically used to store data needed for signout from those protocols. + var additionalLocalClaims = new List(); + var localSignInProps = new AuthenticationProperties(); + ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); + + // issue authentication cookie for user + var isuser = new IdentityServerUser(user.Id) + { + DisplayName = user.UserName, + IdentityProvider = provider, + AdditionalClaims = additionalLocalClaims + }; + + await HttpContext.SignInAsync(isuser, localSignInProps); + + // delete temporary cookie used during external authentication + await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); - // retrieve return URL - var returnUrl = result.Properties.Items["returnUrl"] ?? "~/"; + // retrieve return URL + var returnUrl = result.Properties.Items["returnUrl"] ?? "~/"; - // check if external login is in the context of an OIDC request - var context = await _interaction.GetAuthorizationContextAsync(returnUrl); - await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, user.UserName, true, context?.Client.ClientId)); + // check if external login is in the context of an OIDC request + var context = await _interaction.GetAuthorizationContextAsync(returnUrl); + await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, user.UserName, true, context?.Client.ClientId)); - if (context != null) + if (context != null) + { + if (context.IsNativeClient()) { - if (context.IsNativeClient()) - { - // The client is native, so this change in how to - // return the response is for better UX for the end user. - return this.LoadingPage("Redirect", returnUrl); - } + // The client is native, so this change in how to + // return the response is for better UX for the end user. + return this.LoadingPage("Redirect", returnUrl); } - - return Redirect(returnUrl); } - private async Task<(IdentityUser user, string provider, string providerUserId, IEnumerable claims)> FindUserFromExternalProvider(AuthenticateResult result) - { - var externalUser = result.Principal; + return Redirect(returnUrl); + } - // try to determine the unique id of the external user (issued by the provider) - // the most common claim type for that are the sub claim and the NameIdentifier - // depending on the external provider, some other claim type might be used - var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ?? - externalUser.FindFirst(ClaimTypes.NameIdentifier) ?? - throw new Exception("Unknown userid"); + private async Task<(IdentityUser user, string provider, string providerUserId, IEnumerable claims)> FindUserFromExternalProvider(AuthenticateResult result) + { + var externalUser = result.Principal; - // remove the user id claim so we don't include it as an extra claim if/when we provision the user - var claims = externalUser.Claims.ToList(); - claims.Remove(userIdClaim); + // try to determine the unique id of the external user (issued by the provider) + // the most common claim type for that are the sub claim and the NameIdentifier + // depending on the external provider, some other claim type might be used + var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ?? + externalUser.FindFirst(ClaimTypes.NameIdentifier) ?? + throw new Exception("Unknown userid"); - var provider = result.Properties.Items["scheme"]; - var providerUserId = userIdClaim.Value; + // remove the user id claim so we don't include it as an extra claim if/when we provision the user + var claims = externalUser.Claims.ToList(); + claims.Remove(userIdClaim); - // find external user - var user = await _userManager.FindByLoginAsync(provider, providerUserId); + var provider = result.Properties.Items["scheme"]; + var providerUserId = userIdClaim.Value; - return (user, provider, providerUserId, claims); - } + // find external user + var user = await _userManager.FindByLoginAsync(provider, providerUserId); - private async Task AutoProvisionUser(string provider, string providerUserId, IEnumerable claims) - { - // create dummy internal account (you can do something more complex) - var user = new IdentityUser(Guid.NewGuid().ToString()); - await _userManager.CreateAsync(user); + return (user, provider, providerUserId, claims); + } - // add external user ID to new account - await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerUserId, provider)); - return user; - } + private async Task AutoProvisionUser(string provider, string providerUserId, IEnumerable claims) + { + // create dummy internal account (you can do something more complex) + var user = new IdentityUser(Guid.NewGuid().ToString()); + await _userManager.CreateAsync(user); + + // add external user ID to new account + await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerUserId, provider)); + return user; + } - // if the external login is OIDC-based, there are certain things we need to preserve to make logout work - // this will be different for WS-Fed, SAML2p or other protocols - private void ProcessLoginCallback(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps) + // if the external login is OIDC-based, there are certain things we need to preserve to make logout work + // this will be different for WS-Fed, SAML2p or other protocols + private void ProcessLoginCallback(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps) + { + // if the external system sent a session id claim, copy it over + // so we can use it for single sign-out + var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId); + if (sid != null) { - // if the external system sent a session id claim, copy it over - // so we can use it for single sign-out - var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId); - if (sid != null) - { - localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value)); - } + localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value)); + } - // if the external provider issued an id_token, we'll keep it for signout - var idToken = externalResult.Properties.GetTokenValue("id_token"); - if (idToken != null) - { - localSignInProps.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = idToken } }); - } + // if the external provider issued an id_token, we'll keep it for signout + var idToken = externalResult.Properties.GetTokenValue("id_token"); + if (idToken != null) + { + localSignInProps.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = idToken } }); } } -} \ No newline at end of file +} diff --git a/IdentityProvider/Quickstart/Account/ExternalProvider.cs b/IdentityProvider/Quickstart/Account/ExternalProvider.cs index 72a64c3..8c5cc53 100644 --- a/IdentityProvider/Quickstart/Account/ExternalProvider.cs +++ b/IdentityProvider/Quickstart/Account/ExternalProvider.cs @@ -1,12 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -namespace IdentityServerHost.Quickstart.UI +public class ExternalProvider { - public class ExternalProvider - { - public string DisplayName { get; set; } - public string AuthenticationScheme { get; set; } - } -} \ No newline at end of file + public string DisplayName { get; set; } + public string AuthenticationScheme { get; set; } +} diff --git a/IdentityProvider/Quickstart/Account/LoggedOutViewModel.cs b/IdentityProvider/Quickstart/Account/LoggedOutViewModel.cs index 8b2a719..8afa275 100644 --- a/IdentityProvider/Quickstart/Account/LoggedOutViewModel.cs +++ b/IdentityProvider/Quickstart/Account/LoggedOutViewModel.cs @@ -1,19 +1,17 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -namespace IdentityServerHost.Quickstart.UI +public class LoggedOutViewModel { - public class LoggedOutViewModel - { - public string PostLogoutRedirectUri { get; set; } - public string ClientName { get; set; } - public string SignOutIframeUrl { get; set; } + public string PostLogoutRedirectUri { get; set; } + public string ClientName { get; set; } + public string SignOutIframeUrl { get; set; } - public bool AutomaticRedirectAfterSignOut { get; set; } + public bool AutomaticRedirectAfterSignOut { get; set; } - public string LogoutId { get; set; } - public bool TriggerExternalSignout => ExternalAuthenticationScheme != null; - public string ExternalAuthenticationScheme { get; set; } - } -} \ No newline at end of file + public string LogoutId { get; set; } + public bool TriggerExternalSignout => ExternalAuthenticationScheme != null; + public string ExternalAuthenticationScheme { get; set; } +} diff --git a/IdentityProvider/Quickstart/Account/LoginInputModel.cs b/IdentityProvider/Quickstart/Account/LoginInputModel.cs index fecc1ed..921f607 100644 --- a/IdentityProvider/Quickstart/Account/LoginInputModel.cs +++ b/IdentityProvider/Quickstart/Account/LoginInputModel.cs @@ -1,18 +1,14 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System.ComponentModel.DataAnnotations; - -namespace IdentityServerHost.Quickstart.UI +public class LoginInputModel { - public class LoginInputModel - { - [Required] - public string Username { get; set; } - [Required] - public string Password { get; set; } - public bool RememberLogin { get; set; } - public string ReturnUrl { get; set; } - } -} \ No newline at end of file + [Required] + public string Username { get; set; } + [Required] + public string Password { get; set; } + public bool RememberLogin { get; set; } + public string ReturnUrl { get; set; } +} diff --git a/IdentityProvider/Quickstart/Account/LoginViewModel.cs b/IdentityProvider/Quickstart/Account/LoginViewModel.cs index aa63aba..78c7da9 100644 --- a/IdentityProvider/Quickstart/Account/LoginViewModel.cs +++ b/IdentityProvider/Quickstart/Account/LoginViewModel.cs @@ -1,22 +1,16 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace IdentityServerHost.Quickstart.UI +public class LoginViewModel : LoginInputModel { - public class LoginViewModel : LoginInputModel - { - public bool AllowRememberLogin { get; set; } = true; - public bool EnableLocalLogin { get; set; } = true; + public bool AllowRememberLogin { get; set; } = true; + public bool EnableLocalLogin { get; set; } = true; - public IEnumerable ExternalProviders { get; set; } = Enumerable.Empty(); - public IEnumerable VisibleExternalProviders => ExternalProviders.Where(x => !String.IsNullOrWhiteSpace(x.DisplayName)); + public IEnumerable ExternalProviders { get; set; } = Enumerable.Empty(); + public IEnumerable VisibleExternalProviders => ExternalProviders.Where(x => !String.IsNullOrWhiteSpace(x.DisplayName)); - public bool IsExternalLoginOnly => EnableLocalLogin == false && ExternalProviders?.Count() == 1; - public string ExternalLoginScheme => IsExternalLoginOnly ? ExternalProviders?.SingleOrDefault()?.AuthenticationScheme : null; - } -} \ No newline at end of file + public bool IsExternalLoginOnly => EnableLocalLogin == false && ExternalProviders?.Count() == 1; + public string ExternalLoginScheme => IsExternalLoginOnly ? ExternalProviders?.SingleOrDefault()?.AuthenticationScheme : null; +} diff --git a/IdentityProvider/Quickstart/Account/LogoutInputModel.cs b/IdentityProvider/Quickstart/Account/LogoutInputModel.cs index debc4e6..cb45bd1 100644 --- a/IdentityProvider/Quickstart/Account/LogoutInputModel.cs +++ b/IdentityProvider/Quickstart/Account/LogoutInputModel.cs @@ -1,11 +1,9 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -namespace IdentityServerHost.Quickstart.UI +public class LogoutInputModel { - public class LogoutInputModel - { - public string LogoutId { get; set; } - } + public string LogoutId { get; set; } } diff --git a/IdentityProvider/Quickstart/Account/LogoutViewModel.cs b/IdentityProvider/Quickstart/Account/LogoutViewModel.cs index 29e39a4..c7d7fff 100644 --- a/IdentityProvider/Quickstart/Account/LogoutViewModel.cs +++ b/IdentityProvider/Quickstart/Account/LogoutViewModel.cs @@ -2,10 +2,10 @@ // See LICENSE in the project root for license information. -namespace IdentityServerHost.Quickstart.UI +namespace IdentityServerHost.Quickstart.UI; + +public class LogoutViewModel : LogoutInputModel { - public class LogoutViewModel : LogoutInputModel - { - public bool ShowLogoutPrompt { get; set; } = true; - } + public bool ShowLogoutPrompt { get; set; } = true; } + diff --git a/IdentityProvider/Quickstart/Account/RedirectViewModel.cs b/IdentityProvider/Quickstart/Account/RedirectViewModel.cs index 7f16b42..016d5d0 100644 --- a/IdentityProvider/Quickstart/Account/RedirectViewModel.cs +++ b/IdentityProvider/Quickstart/Account/RedirectViewModel.cs @@ -1,12 +1,9 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; - -namespace IdentityServerHost.Quickstart.UI +public class RedirectViewModel { - public class RedirectViewModel - { - public string RedirectUrl { get; set; } - } -} \ No newline at end of file + public string RedirectUrl { get; set; } +} diff --git a/IdentityProvider/Quickstart/Consent/ConsentController.cs b/IdentityProvider/Quickstart/Consent/ConsentController.cs index 649a4f5..fa05f1f 100644 --- a/IdentityProvider/Quickstart/Consent/ConsentController.cs +++ b/IdentityProvider/Quickstart/Consent/ConsentController.cs @@ -1,276 +1,260 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System.Linq; -using System.Threading.Tasks; -using System.Collections.Generic; -using System; -using Duende.IdentityServer.Events; -using Duende.IdentityServer.Extensions; -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Services; -using Duende.IdentityServer.Validation; -using IdentityModel; - -namespace IdentityServerHost.Quickstart.UI +namespace IdentityServerHost.Quickstart.UI; + +/// +/// This controller processes the consent UI +/// +[SecurityHeaders] +[Authorize] +public class ConsentController : Controller { + private readonly IIdentityServerInteractionService _interaction; + private readonly IEventService _events; + private readonly ILogger _logger; + + public ConsentController( + IIdentityServerInteractionService interaction, + IEventService events, + ILogger logger) + { + _interaction = interaction; + _events = events; + _logger = logger; + } + /// - /// This controller processes the consent UI + /// Shows the consent screen /// - [SecurityHeaders] - [Authorize] - public class ConsentController : Controller + /// + /// + [HttpGet] + public async Task Index(string returnUrl) { - private readonly IIdentityServerInteractionService _interaction; - private readonly IEventService _events; - private readonly ILogger _logger; - - public ConsentController( - IIdentityServerInteractionService interaction, - IEventService events, - ILogger logger) + var vm = await BuildViewModelAsync(returnUrl); + if (vm != null) { - _interaction = interaction; - _events = events; - _logger = logger; + return View("Index", vm); } - /// - /// Shows the consent screen - /// - /// - /// - [HttpGet] - public async Task Index(string returnUrl) - { - var vm = await BuildViewModelAsync(returnUrl); - if (vm != null) - { - return View("Index", vm); - } + return View("Error"); + } - return View("Error"); - } + /// + /// Handles the consent screen postback + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Index(ConsentInputModel model) + { + var result = await ProcessConsent(model); - /// - /// Handles the consent screen postback - /// - [HttpPost] - [ValidateAntiForgeryToken] - public async Task Index(ConsentInputModel model) + if (result.IsRedirect) { - var result = await ProcessConsent(model); - - if (result.IsRedirect) + var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); + if (context?.IsNativeClient() == true) { - var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); - if (context?.IsNativeClient() == true) - { - // The client is native, so this change in how to - // return the response is for better UX for the end user. - return this.LoadingPage("Redirect", result.RedirectUri); - } - - return Redirect(result.RedirectUri); + // The client is native, so this change in how to + // return the response is for better UX for the end user. + return this.LoadingPage("Redirect", result.RedirectUri); } - if (result.HasValidationError) - { - ModelState.AddModelError(string.Empty, result.ValidationError); - } - - if (result.ShowView) - { - return View("Index", result.ViewModel); - } + return Redirect(result.RedirectUri); + } - return View("Error"); + if (result.HasValidationError) + { + ModelState.AddModelError(string.Empty, result.ValidationError); } - /*****************************************/ - /* helper APIs for the ConsentController */ - /*****************************************/ - private async Task ProcessConsent(ConsentInputModel model) + if (result.ShowView) { - var result = new ProcessConsentResult(); + return View("Index", result.ViewModel); + } - // validate return url is still valid - var request = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); - if (request == null) return result; + return View("Error"); + } - ConsentResponse grantedConsent = null; + /*****************************************/ + /* helper APIs for the ConsentController */ + /*****************************************/ + private async Task ProcessConsent(ConsentInputModel model) + { + var result = new ProcessConsentResult(); - // user clicked 'no' - send back the standard 'access_denied' response - if (model?.Button == "no") - { - grantedConsent = new ConsentResponse { Error = AuthorizationError.AccessDenied }; + // validate return url is still valid + var request = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); + if (request == null) return result; - // emit event - await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); - } - // user clicked 'yes' - validate the data - else if (model?.Button == "yes") - { - // if the user consented to some scope, build the response model - if (model.ScopesConsented != null && model.ScopesConsented.Any()) - { - var scopes = model.ScopesConsented; - if (ConsentOptions.EnableOfflineAccess == false) - { - scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess); - } + ConsentResponse grantedConsent = null; - grantedConsent = new ConsentResponse - { - RememberConsent = model.RememberConsent, - ScopesValuesConsented = scopes.ToArray(), - Description = model.Description - }; + // user clicked 'no' - send back the standard 'access_denied' response + if (model?.Button == "no") + { + grantedConsent = new ConsentResponse { Error = AuthorizationError.AccessDenied }; - // emit event - await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); - } - else + // emit event + await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + } + // user clicked 'yes' - validate the data + else if (model?.Button == "yes") + { + // if the user consented to some scope, build the response model + if (model.ScopesConsented != null && model.ScopesConsented.Any()) + { + var scopes = model.ScopesConsented; + if (ConsentOptions.EnableOfflineAccess == false) { - result.ValidationError = ConsentOptions.MustChooseOneErrorMessage; + scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess); } - } - else - { - result.ValidationError = ConsentOptions.InvalidSelectionErrorMessage; - } - if (grantedConsent != null) - { - // communicate outcome of consent back to identityserver - await _interaction.GrantConsentAsync(request, grantedConsent); + grantedConsent = new ConsentResponse + { + RememberConsent = model.RememberConsent, + ScopesValuesConsented = scopes.ToArray(), + Description = model.Description + }; - // indicate that's it ok to redirect back to authorization endpoint - result.RedirectUri = model.ReturnUrl; - result.Client = request.Client; + // emit event + await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); } else { - // we need to redisplay the consent UI - result.ViewModel = await BuildViewModelAsync(model.ReturnUrl, model); + result.ValidationError = ConsentOptions.MustChooseOneErrorMessage; } - - return result; + } + else + { + result.ValidationError = ConsentOptions.InvalidSelectionErrorMessage; } - private async Task BuildViewModelAsync(string returnUrl, ConsentInputModel model = null) + if (grantedConsent != null) { - var request = await _interaction.GetAuthorizationContextAsync(returnUrl); - if (request != null) - { - return CreateConsentViewModel(model, returnUrl, request); - } - else - { - _logger.LogError("No consent request matching request: {0}", returnUrl); - } + // communicate outcome of consent back to identityserver + await _interaction.GrantConsentAsync(request, grantedConsent); - return null; + // indicate that's it ok to redirect back to authorization endpoint + result.RedirectUri = model.ReturnUrl; + result.Client = request.Client; + } + else + { + // we need to redisplay the consent UI + result.ViewModel = await BuildViewModelAsync(model.ReturnUrl, model); } - private ConsentViewModel CreateConsentViewModel( - ConsentInputModel model, string returnUrl, - AuthorizationRequest request) + return result; + } + + private async Task BuildViewModelAsync(string returnUrl, ConsentInputModel model = null) + { + var request = await _interaction.GetAuthorizationContextAsync(returnUrl); + if (request != null) { - var vm = new ConsentViewModel - { - RememberConsent = model?.RememberConsent ?? true, - ScopesConsented = model?.ScopesConsented ?? Enumerable.Empty(), - Description = model?.Description, + return CreateConsentViewModel(model, returnUrl, request); + } + else + { + _logger.LogError("No consent request matching request: {0}", returnUrl); + } - ReturnUrl = returnUrl, + return null; + } - ClientName = request.Client.ClientName ?? request.Client.ClientId, - ClientUrl = request.Client.ClientUri, - ClientLogoUrl = request.Client.LogoUri, - AllowRememberConsent = request.Client.AllowRememberConsent - }; + private ConsentViewModel CreateConsentViewModel( + ConsentInputModel model, string returnUrl, + AuthorizationRequest request) + { + var vm = new ConsentViewModel + { + RememberConsent = model?.RememberConsent ?? true, + ScopesConsented = model?.ScopesConsented ?? Enumerable.Empty(), + Description = model?.Description, - vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources - .Select(x => CreateScopeViewModel(x, vm.ScopesConsented.Contains(x.Name) || model == null)) - .ToArray(); + ReturnUrl = returnUrl, - var resourceIndicators = request.Parameters.GetValues(OidcConstants.AuthorizeRequest.Resource) ?? Enumerable.Empty(); - var apiResources = request.ValidatedResources.Resources.ApiResources.Where(x => resourceIndicators.Contains(x.Name)); + ClientName = request.Client.ClientName ?? request.Client.ClientId, + ClientUrl = request.Client.ClientUri, + ClientLogoUrl = request.Client.LogoUri, + AllowRememberConsent = request.Client.AllowRememberConsent + }; - var apiScopes = new List(); - foreach (var parsedScope in request.ValidatedResources.ParsedScopes) - { - var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); - if (apiScope != null) - { - var scopeVm = CreateScopeViewModel(parsedScope, apiScope, vm.ScopesConsented.Contains(parsedScope.RawValue) || model == null); - scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName)) - .Select(x=> new ResourceViewModel - { - Name = x.Name, - DisplayName = x.DisplayName ?? x.Name, - }).ToArray(); - apiScopes.Add(scopeVm); - } - } - if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) - { - apiScopes.Add(GetOfflineAccessScope(vm.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess) || model == null)); - } - vm.ApiScopes = apiScopes; + vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources + .Select(x => CreateScopeViewModel(x, vm.ScopesConsented.Contains(x.Name) || model == null)) + .ToArray(); - return vm; - } + var resourceIndicators = request.Parameters.GetValues(OidcConstants.AuthorizeRequest.Resource) ?? Enumerable.Empty(); + var apiResources = request.ValidatedResources.Resources.ApiResources.Where(x => resourceIndicators.Contains(x.Name)); - private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + var apiScopes = new List(); + foreach (var parsedScope in request.ValidatedResources.ParsedScopes) { - return new ScopeViewModel + var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); + if (apiScope != null) { - Name = identity.Name, - Value = identity.Name, - DisplayName = identity.DisplayName ?? identity.Name, - Description = identity.Description, - Emphasize = identity.Emphasize, - Required = identity.Required, - Checked = check || identity.Required - }; + var scopeVm = CreateScopeViewModel(parsedScope, apiScope, vm.ScopesConsented.Contains(parsedScope.RawValue) || model == null); + scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName)) + .Select(x=> new ResourceViewModel + { + Name = x.Name, + DisplayName = x.DisplayName ?? x.Name, + }).ToArray(); + apiScopes.Add(scopeVm); + } } + if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) + { + apiScopes.Add(GetOfflineAccessScope(vm.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess) || model == null)); + } + vm.ApiScopes = apiScopes; + + return vm; + } - public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + { + return new ScopeViewModel { - var displayName = apiScope.DisplayName ?? apiScope.Name; - if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter)) - { - displayName += ":" + parsedScopeValue.ParsedParameter; - } + Name = identity.Name, + Value = identity.Name, + DisplayName = identity.DisplayName ?? identity.Name, + Description = identity.Description, + Emphasize = identity.Emphasize, + Required = identity.Required, + Checked = check || identity.Required + }; + } - return new ScopeViewModel - { - Name = parsedScopeValue.ParsedName, - Value = parsedScopeValue.RawValue, - DisplayName = displayName, - Description = apiScope.Description, - Emphasize = apiScope.Emphasize, - Required = apiScope.Required, - Checked = check || apiScope.Required - }; + public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + { + var displayName = apiScope.DisplayName ?? apiScope.Name; + if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter)) + { + displayName += ":" + parsedScopeValue.ParsedParameter; } - private ScopeViewModel GetOfflineAccessScope(bool check) + return new ScopeViewModel { - return new ScopeViewModel - { - Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess, - DisplayName = ConsentOptions.OfflineAccessDisplayName, - Description = ConsentOptions.OfflineAccessDescription, - Emphasize = true, - Checked = check - }; - } + Name = parsedScopeValue.ParsedName, + Value = parsedScopeValue.RawValue, + DisplayName = displayName, + Description = apiScope.Description, + Emphasize = apiScope.Emphasize, + Required = apiScope.Required, + Checked = check || apiScope.Required + }; + } + + private ScopeViewModel GetOfflineAccessScope(bool check) + { + return new ScopeViewModel + { + Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess, + DisplayName = ConsentOptions.OfflineAccessDisplayName, + Description = ConsentOptions.OfflineAccessDescription, + Emphasize = true, + Checked = check + }; } -} \ No newline at end of file +} diff --git a/IdentityProvider/Quickstart/Consent/ConsentInputModel.cs b/IdentityProvider/Quickstart/Consent/ConsentInputModel.cs index 10d7f47..c8266a0 100644 --- a/IdentityProvider/Quickstart/Consent/ConsentInputModel.cs +++ b/IdentityProvider/Quickstart/Consent/ConsentInputModel.cs @@ -1,17 +1,13 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System.Collections.Generic; - -namespace IdentityServerHost.Quickstart.UI +public class ConsentInputModel { - public class ConsentInputModel - { - public string Button { get; set; } - public IEnumerable ScopesConsented { get; set; } - public bool RememberConsent { get; set; } - public string ReturnUrl { get; set; } - public string Description { get; set; } - } -} \ No newline at end of file + public string Button { get; set; } + public IEnumerable ScopesConsented { get; set; } + public bool RememberConsent { get; set; } + public string ReturnUrl { get; set; } + public string Description { get; set; } +} diff --git a/IdentityProvider/Quickstart/Consent/ConsentOptions.cs b/IdentityProvider/Quickstart/Consent/ConsentOptions.cs index d436d9c..6ffbf12 100644 --- a/IdentityProvider/Quickstart/Consent/ConsentOptions.cs +++ b/IdentityProvider/Quickstart/Consent/ConsentOptions.cs @@ -2,15 +2,15 @@ // See LICENSE in the project root for license information. -namespace IdentityServerHost.Quickstart.UI +namespace IdentityServerHost.Quickstart.UI; + +public class ConsentOptions { - public class ConsentOptions - { - public static bool EnableOfflineAccess = true; - public static string OfflineAccessDisplayName = "Offline Access"; - public static string OfflineAccessDescription = "Access to your applications and resources, even when you are offline"; + public static bool EnableOfflineAccess = true; + public static string OfflineAccessDisplayName = "Offline Access"; + public static string OfflineAccessDescription = "Access to your applications and resources, even when you are offline"; - public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission"; - public static readonly string InvalidSelectionErrorMessage = "Invalid selection"; - } + public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission"; + public static readonly string InvalidSelectionErrorMessage = "Invalid selection"; } + diff --git a/IdentityProvider/Quickstart/Consent/ConsentViewModel.cs b/IdentityProvider/Quickstart/Consent/ConsentViewModel.cs index f80edcd..b991fa1 100644 --- a/IdentityProvider/Quickstart/Consent/ConsentViewModel.cs +++ b/IdentityProvider/Quickstart/Consent/ConsentViewModel.cs @@ -1,19 +1,16 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System.Collections.Generic; - -namespace IdentityServerHost.Quickstart.UI +public class ConsentViewModel : ConsentInputModel { - public class ConsentViewModel : ConsentInputModel - { - public string ClientName { get; set; } - public string ClientUrl { get; set; } - public string ClientLogoUrl { get; set; } - public bool AllowRememberConsent { get; set; } + public string ClientName { get; set; } + public string ClientUrl { get; set; } + public string ClientLogoUrl { get; set; } + public bool AllowRememberConsent { get; set; } - public IEnumerable IdentityScopes { get; set; } - public IEnumerable ApiScopes { get; set; } - } + public IEnumerable IdentityScopes { get; set; } + public IEnumerable ApiScopes { get; set; } } + diff --git a/IdentityProvider/Quickstart/Consent/ProcessConsentResult.cs b/IdentityProvider/Quickstart/Consent/ProcessConsentResult.cs index da50194..6b82e24 100644 --- a/IdentityProvider/Quickstart/Consent/ProcessConsentResult.cs +++ b/IdentityProvider/Quickstart/Consent/ProcessConsentResult.cs @@ -1,21 +1,18 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using Duende.IdentityServer.Models; - -namespace IdentityServerHost.Quickstart.UI +public class ProcessConsentResult { - public class ProcessConsentResult - { - public bool IsRedirect => RedirectUri != null; - public string RedirectUri { get; set; } - public Client Client { get; set; } + public bool IsRedirect => RedirectUri != null; + public string RedirectUri { get; set; } + public Client Client { get; set; } - public bool ShowView => ViewModel != null; - public ConsentViewModel ViewModel { get; set; } + public bool ShowView => ViewModel != null; + public ConsentViewModel ViewModel { get; set; } - public bool HasValidationError => ValidationError != null; - public string ValidationError { get; set; } - } + public bool HasValidationError => ValidationError != null; + public string ValidationError { get; set; } } + diff --git a/IdentityProvider/Quickstart/Consent/ResourceViewModel.cs b/IdentityProvider/Quickstart/Consent/ResourceViewModel.cs index 1ae6a8e..cf643b2 100644 --- a/IdentityProvider/Quickstart/Consent/ResourceViewModel.cs +++ b/IdentityProvider/Quickstart/Consent/ResourceViewModel.cs @@ -1,12 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -namespace IdentityServerHost.Quickstart.UI +public class ResourceViewModel { - public class ResourceViewModel - { - public string Name { get; set; } - public string DisplayName { get; set; } - } + public string Name { get; set; } + public string DisplayName { get; set; } } diff --git a/IdentityProvider/Quickstart/Consent/ScopeViewModel.cs b/IdentityProvider/Quickstart/Consent/ScopeViewModel.cs index e76b189..a625e41 100644 --- a/IdentityProvider/Quickstart/Consent/ScopeViewModel.cs +++ b/IdentityProvider/Quickstart/Consent/ScopeViewModel.cs @@ -1,20 +1,17 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System.Collections.Generic; - -namespace IdentityServerHost.Quickstart.UI +public class ScopeViewModel { - public class ScopeViewModel - { - public string Name { get; set; } - public string Value { get; set; } - public string DisplayName { get; set; } - public string Description { get; set; } - public bool Emphasize { get; set; } - public bool Required { get; set; } - public bool Checked { get; set; } - public IEnumerable Resources { get; set; } - } + public string Name { get; set; } + public string Value { get; set; } + public string DisplayName { get; set; } + public string Description { get; set; } + public bool Emphasize { get; set; } + public bool Required { get; set; } + public bool Checked { get; set; } + public IEnumerable Resources { get; set; } } + diff --git a/IdentityProvider/Quickstart/Device/DeviceAuthorizationInputModel.cs b/IdentityProvider/Quickstart/Device/DeviceAuthorizationInputModel.cs index 272442a..25e77ce 100644 --- a/IdentityProvider/Quickstart/Device/DeviceAuthorizationInputModel.cs +++ b/IdentityProvider/Quickstart/Device/DeviceAuthorizationInputModel.cs @@ -1,11 +1,9 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -namespace IdentityServerHost.Quickstart.UI +public class DeviceAuthorizationInputModel : ConsentInputModel { - public class DeviceAuthorizationInputModel : ConsentInputModel - { - public string UserCode { get; set; } - } -} \ No newline at end of file + public string UserCode { get; set; } +} diff --git a/IdentityProvider/Quickstart/Device/DeviceAuthorizationViewModel.cs b/IdentityProvider/Quickstart/Device/DeviceAuthorizationViewModel.cs index 8cf030c..7ad4503 100644 --- a/IdentityProvider/Quickstart/Device/DeviceAuthorizationViewModel.cs +++ b/IdentityProvider/Quickstart/Device/DeviceAuthorizationViewModel.cs @@ -1,12 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -namespace IdentityServerHost.Quickstart.UI +public class DeviceAuthorizationViewModel : ConsentViewModel { - public class DeviceAuthorizationViewModel : ConsentViewModel - { - public string UserCode { get; set; } - public bool ConfirmUserCode { get; set; } - } -} \ No newline at end of file + public string UserCode { get; set; } + public bool ConfirmUserCode { get; set; } +} diff --git a/IdentityProvider/Quickstart/Device/DeviceController.cs b/IdentityProvider/Quickstart/Device/DeviceController.cs index 9e69aee..f3b981c 100644 --- a/IdentityProvider/Quickstart/Device/DeviceController.cs +++ b/IdentityProvider/Quickstart/Device/DeviceController.cs @@ -1,232 +1,215 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Duende.IdentityServer.Configuration; -using Duende.IdentityServer.Events; -using Duende.IdentityServer.Extensions; -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Services; -using Duende.IdentityServer.Validation; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace IdentityServerHost.Quickstart.UI +[Authorize] +[SecurityHeaders] +public class DeviceController : Controller { - [Authorize] - [SecurityHeaders] - public class DeviceController : Controller + private readonly IDeviceFlowInteractionService _interaction; + private readonly IEventService _events; + private readonly IOptions _options; + private readonly ILogger _logger; + + public DeviceController( + IDeviceFlowInteractionService interaction, + IEventService eventService, + IOptions options, + ILogger logger) { - private readonly IDeviceFlowInteractionService _interaction; - private readonly IEventService _events; - private readonly IOptions _options; - private readonly ILogger _logger; - - public DeviceController( - IDeviceFlowInteractionService interaction, - IEventService eventService, - IOptions options, - ILogger logger) - { - _interaction = interaction; - _events = eventService; - _options = options; - _logger = logger; - } + _interaction = interaction; + _events = eventService; + _options = options; + _logger = logger; + } - [HttpGet] - public async Task Index() - { - string userCodeParamName = _options.Value.UserInteraction.DeviceVerificationUserCodeParameter; - string userCode = Request.Query[userCodeParamName]; - if (string.IsNullOrWhiteSpace(userCode)) return View("UserCodeCapture"); + [HttpGet] + public async Task Index() + { + string userCodeParamName = _options.Value.UserInteraction.DeviceVerificationUserCodeParameter; + string userCode = Request.Query[userCodeParamName]; + if (string.IsNullOrWhiteSpace(userCode)) return View("UserCodeCapture"); - var vm = await BuildViewModelAsync(userCode); - if (vm == null) return View("Error"); + var vm = await BuildViewModelAsync(userCode); + if (vm == null) return View("Error"); - vm.ConfirmUserCode = true; - return View("UserCodeConfirmation", vm); - } + vm.ConfirmUserCode = true; + return View("UserCodeConfirmation", vm); + } - [HttpPost] - [ValidateAntiForgeryToken] - public async Task UserCodeCapture(string userCode) - { - var vm = await BuildViewModelAsync(userCode); - if (vm == null) return View("Error"); + [HttpPost] + [ValidateAntiForgeryToken] + public async Task UserCodeCapture(string userCode) + { + var vm = await BuildViewModelAsync(userCode); + if (vm == null) return View("Error"); - return View("UserCodeConfirmation", vm); - } + return View("UserCodeConfirmation", vm); + } - [HttpPost] - [ValidateAntiForgeryToken] - public async Task Callback(DeviceAuthorizationInputModel model) - { - if (model == null) throw new ArgumentNullException(nameof(model)); + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Callback(DeviceAuthorizationInputModel model) + { + if (model == null) throw new ArgumentNullException(nameof(model)); - var result = await ProcessConsent(model); - if (result.HasValidationError) return View("Error"); + var result = await ProcessConsent(model); + if (result.HasValidationError) return View("Error"); - return View("Success"); - } + return View("Success"); + } - private async Task ProcessConsent(DeviceAuthorizationInputModel model) - { - var result = new ProcessConsentResult(); + private async Task ProcessConsent(DeviceAuthorizationInputModel model) + { + var result = new ProcessConsentResult(); - var request = await _interaction.GetAuthorizationContextAsync(model.UserCode); - if (request == null) return result; + var request = await _interaction.GetAuthorizationContextAsync(model.UserCode); + if (request == null) return result; - ConsentResponse grantedConsent = null; + ConsentResponse grantedConsent = null; - // user clicked 'no' - send back the standard 'access_denied' response - if (model.Button == "no") - { - grantedConsent = new ConsentResponse { Error = AuthorizationError.AccessDenied }; + // user clicked 'no' - send back the standard 'access_denied' response + if (model.Button == "no") + { + grantedConsent = new ConsentResponse { Error = AuthorizationError.AccessDenied }; - // emit event - await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); - } - // user clicked 'yes' - validate the data - else if (model.Button == "yes") + // emit event + await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + } + // user clicked 'yes' - validate the data + else if (model.Button == "yes") + { + // if the user consented to some scope, build the response model + if (model.ScopesConsented != null && model.ScopesConsented.Any()) { - // if the user consented to some scope, build the response model - if (model.ScopesConsented != null && model.ScopesConsented.Any()) + var scopes = model.ScopesConsented; + if (ConsentOptions.EnableOfflineAccess == false) { - var scopes = model.ScopesConsented; - if (ConsentOptions.EnableOfflineAccess == false) - { - scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess); - } - - grantedConsent = new ConsentResponse - { - RememberConsent = model.RememberConsent, - ScopesValuesConsented = scopes.ToArray(), - Description = model.Description - }; - - // emit event - await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess); } - else - { - result.ValidationError = ConsentOptions.MustChooseOneErrorMessage; - } - } - else - { - result.ValidationError = ConsentOptions.InvalidSelectionErrorMessage; - } - if (grantedConsent != null) - { - // communicate outcome of consent back to identityserver - await _interaction.HandleRequestAsync(model.UserCode, grantedConsent); + grantedConsent = new ConsentResponse + { + RememberConsent = model.RememberConsent, + ScopesValuesConsented = scopes.ToArray(), + Description = model.Description + }; - // indicate that's it ok to redirect back to authorization endpoint - result.RedirectUri = model.ReturnUrl; - result.Client = request.Client; + // emit event + await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); } else { - // we need to redisplay the consent UI - result.ViewModel = await BuildViewModelAsync(model.UserCode, model); + result.ValidationError = ConsentOptions.MustChooseOneErrorMessage; } - - return result; + } + else + { + result.ValidationError = ConsentOptions.InvalidSelectionErrorMessage; } - private async Task BuildViewModelAsync(string userCode, DeviceAuthorizationInputModel model = null) + if (grantedConsent != null) { - var request = await _interaction.GetAuthorizationContextAsync(userCode); - if (request != null) - { - return CreateConsentViewModel(userCode, model, request); - } + // communicate outcome of consent back to identityserver + await _interaction.HandleRequestAsync(model.UserCode, grantedConsent); - return null; + // indicate that's it ok to redirect back to authorization endpoint + result.RedirectUri = model.ReturnUrl; + result.Client = request.Client; + } + else + { + // we need to redisplay the consent UI + result.ViewModel = await BuildViewModelAsync(model.UserCode, model); } - private DeviceAuthorizationViewModel CreateConsentViewModel(string userCode, DeviceAuthorizationInputModel model, DeviceFlowAuthorizationRequest request) + return result; + } + + private async Task BuildViewModelAsync(string userCode, DeviceAuthorizationInputModel model = null) + { + var request = await _interaction.GetAuthorizationContextAsync(userCode); + if (request != null) { - var vm = new DeviceAuthorizationViewModel - { - UserCode = userCode, - Description = model?.Description, + return CreateConsentViewModel(userCode, model, request); + } - RememberConsent = model?.RememberConsent ?? true, - ScopesConsented = model?.ScopesConsented ?? Enumerable.Empty(), + return null; + } - ClientName = request.Client.ClientName ?? request.Client.ClientId, - ClientUrl = request.Client.ClientUri, - ClientLogoUrl = request.Client.LogoUri, - AllowRememberConsent = request.Client.AllowRememberConsent - }; + private DeviceAuthorizationViewModel CreateConsentViewModel(string userCode, DeviceAuthorizationInputModel model, DeviceFlowAuthorizationRequest request) + { + var vm = new DeviceAuthorizationViewModel + { + UserCode = userCode, + Description = model?.Description, - vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, vm.ScopesConsented.Contains(x.Name) || model == null)).ToArray(); + RememberConsent = model?.RememberConsent ?? true, + ScopesConsented = model?.ScopesConsented ?? Enumerable.Empty(), - var apiScopes = new List(); - foreach (var parsedScope in request.ValidatedResources.ParsedScopes) - { - var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); - if (apiScope != null) - { - var scopeVm = CreateScopeViewModel(parsedScope, apiScope, vm.ScopesConsented.Contains(parsedScope.RawValue) || model == null); - apiScopes.Add(scopeVm); - } - } - if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) - { - apiScopes.Add(GetOfflineAccessScope(vm.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess) || model == null)); - } - vm.ApiScopes = apiScopes; + ClientName = request.Client.ClientName ?? request.Client.ClientId, + ClientUrl = request.Client.ClientUri, + ClientLogoUrl = request.Client.LogoUri, + AllowRememberConsent = request.Client.AllowRememberConsent + }; - return vm; - } + vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, vm.ScopesConsented.Contains(x.Name) || model == null)).ToArray(); - private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + var apiScopes = new List(); + foreach (var parsedScope in request.ValidatedResources.ParsedScopes) { - return new ScopeViewModel + var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); + if (apiScope != null) { - Value = identity.Name, - DisplayName = identity.DisplayName ?? identity.Name, - Description = identity.Description, - Emphasize = identity.Emphasize, - Required = identity.Required, - Checked = check || identity.Required - }; + var scopeVm = CreateScopeViewModel(parsedScope, apiScope, vm.ScopesConsented.Contains(parsedScope.RawValue) || model == null); + apiScopes.Add(scopeVm); + } } - - public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) { - return new ScopeViewModel - { - Value = parsedScopeValue.RawValue, - // todo: use the parsed scope value in the display? - DisplayName = apiScope.DisplayName ?? apiScope.Name, - Description = apiScope.Description, - Emphasize = apiScope.Emphasize, - Required = apiScope.Required, - Checked = check || apiScope.Required - }; + apiScopes.Add(GetOfflineAccessScope(vm.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess) || model == null)); } - private ScopeViewModel GetOfflineAccessScope(bool check) + vm.ApiScopes = apiScopes; + + return vm; + } + + private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + { + return new ScopeViewModel { - return new ScopeViewModel - { - Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess, - DisplayName = ConsentOptions.OfflineAccessDisplayName, - Description = ConsentOptions.OfflineAccessDescription, - Emphasize = true, - Checked = check - }; - } + Value = identity.Name, + DisplayName = identity.DisplayName ?? identity.Name, + Description = identity.Description, + Emphasize = identity.Emphasize, + Required = identity.Required, + Checked = check || identity.Required + }; + } + + public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + { + return new ScopeViewModel + { + Value = parsedScopeValue.RawValue, + // todo: use the parsed scope value in the display? + DisplayName = apiScope.DisplayName ?? apiScope.Name, + Description = apiScope.Description, + Emphasize = apiScope.Emphasize, + Required = apiScope.Required, + Checked = check || apiScope.Required + }; + } + private ScopeViewModel GetOfflineAccessScope(bool check) + { + return new ScopeViewModel + { + Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess, + DisplayName = ConsentOptions.OfflineAccessDisplayName, + Description = ConsentOptions.OfflineAccessDescription, + Emphasize = true, + Checked = check + }; } -} \ No newline at end of file +} diff --git a/IdentityProvider/Quickstart/Diagnostics/DiagnosticsController.cs b/IdentityProvider/Quickstart/Diagnostics/DiagnosticsController.cs index 07c82e7..5b16047 100644 --- a/IdentityProvider/Quickstart/Diagnostics/DiagnosticsController.cs +++ b/IdentityProvider/Quickstart/Diagnostics/DiagnosticsController.cs @@ -1,29 +1,21 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace IdentityServerHost.Quickstart.UI +[SecurityHeaders] +[Authorize] +public class DiagnosticsController : Controller { - [SecurityHeaders] - [Authorize] - public class DiagnosticsController : Controller + public async Task Index() { - public async Task Index() + var localAddresses = new string[] { "127.0.0.1", "::1", HttpContext.Connection.LocalIpAddress.ToString() }; + if (!localAddresses.Contains(HttpContext.Connection.RemoteIpAddress.ToString())) { - var localAddresses = new string[] { "127.0.0.1", "::1", HttpContext.Connection.LocalIpAddress.ToString() }; - if (!localAddresses.Contains(HttpContext.Connection.RemoteIpAddress.ToString())) - { - return NotFound(); - } - - var model = new DiagnosticsViewModel(await HttpContext.AuthenticateAsync()); - return View(model); + return NotFound(); } + + var model = new DiagnosticsViewModel(await HttpContext.AuthenticateAsync()); + return View(model); } -} \ No newline at end of file +} diff --git a/IdentityProvider/Quickstart/Diagnostics/DiagnosticsViewModel.cs b/IdentityProvider/Quickstart/Diagnostics/DiagnosticsViewModel.cs index 4bfd5cb..6a2f92b 100644 --- a/IdentityProvider/Quickstart/Diagnostics/DiagnosticsViewModel.cs +++ b/IdentityProvider/Quickstart/Diagnostics/DiagnosticsViewModel.cs @@ -1,13 +1,6 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using IdentityModel; -using Microsoft.AspNetCore.Authentication; -using System.Collections.Generic; -using System.Text; -using System.Text.Json; - namespace IdentityServerHost.Quickstart.UI { public class DiagnosticsViewModel diff --git a/IdentityProvider/Quickstart/Extensions.cs b/IdentityProvider/Quickstart/Extensions.cs index 8ed5ab9..45f5bb0 100644 --- a/IdentityProvider/Quickstart/Extensions.cs +++ b/IdentityProvider/Quickstart/Extensions.cs @@ -1,11 +1,6 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using System; -using Duende.IdentityServer.Models; -using Microsoft.AspNetCore.Mvc; - namespace IdentityServerHost.Quickstart.UI { public static class Extensions diff --git a/IdentityProvider/Quickstart/Grants/GrantsController.cs b/IdentityProvider/Quickstart/Grants/GrantsController.cs index db95dfa..13960ae 100644 --- a/IdentityProvider/Quickstart/Grants/GrantsController.cs +++ b/IdentityProvider/Quickstart/Grants/GrantsController.cs @@ -1,97 +1,85 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using Microsoft.AspNetCore.Mvc; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Duende.IdentityServer.Events; -using Duende.IdentityServer.Extensions; -using Duende.IdentityServer.Services; -using Duende.IdentityServer.Stores; -using Microsoft.AspNetCore.Authorization; - -namespace IdentityServerHost.Quickstart.UI +/// +/// This sample controller allows a user to revoke grants given to clients +/// +[SecurityHeaders] +[Authorize] +public class GrantsController : Controller { + private readonly IIdentityServerInteractionService _interaction; + private readonly IClientStore _clients; + private readonly IResourceStore _resources; + private readonly IEventService _events; + + public GrantsController(IIdentityServerInteractionService interaction, + IClientStore clients, + IResourceStore resources, + IEventService events) + { + _interaction = interaction; + _clients = clients; + _resources = resources; + _events = events; + } + /// - /// This sample controller allows a user to revoke grants given to clients + /// Show list of grants /// - [SecurityHeaders] - [Authorize] - public class GrantsController : Controller + [HttpGet] + public async Task Index() { - private readonly IIdentityServerInteractionService _interaction; - private readonly IClientStore _clients; - private readonly IResourceStore _resources; - private readonly IEventService _events; - - public GrantsController(IIdentityServerInteractionService interaction, - IClientStore clients, - IResourceStore resources, - IEventService events) - { - _interaction = interaction; - _clients = clients; - _resources = resources; - _events = events; - } + return View("Index", await BuildViewModelAsync()); + } - /// - /// Show list of grants - /// - [HttpGet] - public async Task Index() - { - return View("Index", await BuildViewModelAsync()); - } + /// + /// Handle postback to revoke a client + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Revoke(string clientId) + { + await _interaction.RevokeUserConsentAsync(clientId); + await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), clientId)); - /// - /// Handle postback to revoke a client - /// - [HttpPost] - [ValidateAntiForgeryToken] - public async Task Revoke(string clientId) - { - await _interaction.RevokeUserConsentAsync(clientId); - await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), clientId)); + return RedirectToAction("Index"); + } - return RedirectToAction("Index"); - } + private async Task BuildViewModelAsync() + { + var grants = await _interaction.GetAllUserGrantsAsync(); - private async Task BuildViewModelAsync() + var list = new List(); + foreach(var grant in grants) { - var grants = await _interaction.GetAllUserGrantsAsync(); - - var list = new List(); - foreach(var grant in grants) + var client = await _clients.FindClientByIdAsync(grant.ClientId); + if (client != null) { - var client = await _clients.FindClientByIdAsync(grant.ClientId); - if (client != null) - { - var resources = await _resources.FindResourcesByScopeAsync(grant.Scopes); + var resources = await _resources.FindResourcesByScopeAsync(grant.Scopes); - var item = new GrantViewModel() - { - ClientId = client.ClientId, - ClientName = client.ClientName ?? client.ClientId, - ClientLogoUrl = client.LogoUri, - ClientUrl = client.ClientUri, - Description = grant.Description, - Created = grant.CreationTime, - Expires = grant.Expiration, - IdentityGrantNames = resources.IdentityResources.Select(x => x.DisplayName ?? x.Name).ToArray(), - ApiGrantNames = resources.ApiScopes.Select(x => x.DisplayName ?? x.Name).ToArray() - }; + var item = new GrantViewModel() + { + ClientId = client.ClientId, + ClientName = client.ClientName ?? client.ClientId, + ClientLogoUrl = client.LogoUri, + ClientUrl = client.ClientUri, + Description = grant.Description, + Created = grant.CreationTime, + Expires = grant.Expiration, + IdentityGrantNames = resources.IdentityResources.Select(x => x.DisplayName ?? x.Name).ToArray(), + ApiGrantNames = resources.ApiScopes.Select(x => x.DisplayName ?? x.Name).ToArray() + }; - list.Add(item); - } + list.Add(item); } - - return new GrantsViewModel - { - Grants = list - }; } + + return new GrantsViewModel + { + Grants = list + }; } -} \ No newline at end of file +} diff --git a/IdentityProvider/Quickstart/Grants/GrantsViewModel.cs b/IdentityProvider/Quickstart/Grants/GrantsViewModel.cs index d7b4009..ac4a224 100644 --- a/IdentityProvider/Quickstart/Grants/GrantsViewModel.cs +++ b/IdentityProvider/Quickstart/Grants/GrantsViewModel.cs @@ -1,27 +1,22 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using System; -using System.Collections.Generic; - -namespace IdentityServerHost.Quickstart.UI +public class GrantsViewModel { - public class GrantsViewModel - { - public IEnumerable Grants { get; set; } - } + public IEnumerable Grants { get; set; } +} - public class GrantViewModel - { - public string ClientId { get; set; } - public string ClientName { get; set; } - public string ClientUrl { get; set; } - public string ClientLogoUrl { get; set; } - public string Description { get; set; } - public DateTime Created { get; set; } - public DateTime? Expires { get; set; } - public IEnumerable IdentityGrantNames { get; set; } - public IEnumerable ApiGrantNames { get; set; } - } -} \ No newline at end of file +public class GrantViewModel +{ + public string ClientId { get; set; } + public string ClientName { get; set; } + public string ClientUrl { get; set; } + public string ClientLogoUrl { get; set; } + public string Description { get; set; } + public DateTime Created { get; set; } + public DateTime? Expires { get; set; } + public IEnumerable IdentityGrantNames { get; set; } + public IEnumerable ApiGrantNames { get; set; } +} diff --git a/IdentityProvider/Quickstart/Home/ErrorViewModel.cs b/IdentityProvider/Quickstart/Home/ErrorViewModel.cs index 349bd50..64a6645 100644 --- a/IdentityProvider/Quickstart/Home/ErrorViewModel.cs +++ b/IdentityProvider/Quickstart/Home/ErrorViewModel.cs @@ -1,22 +1,18 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using Duende.IdentityServer.Models; - -namespace IdentityServerHost.Quickstart.UI +public class ErrorViewModel { - public class ErrorViewModel + public ErrorViewModel() { - public ErrorViewModel() - { - } - - public ErrorViewModel(string error) - { - Error = new ErrorMessage { Error = error }; - } + } - public ErrorMessage Error { get; set; } + public ErrorViewModel(string error) + { + Error = new ErrorMessage { Error = error }; } -} \ No newline at end of file + + public ErrorMessage Error { get; set; } +} diff --git a/IdentityProvider/Quickstart/Home/HomeController.cs b/IdentityProvider/Quickstart/Home/HomeController.cs index d2d9991..9d8f67c 100644 --- a/IdentityProvider/Quickstart/Home/HomeController.cs +++ b/IdentityProvider/Quickstart/Home/HomeController.cs @@ -1,65 +1,55 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System.Threading.Tasks; -using Duende.IdentityServer.Services; - -namespace IdentityServerHost.Quickstart.UI +[SecurityHeaders] +[AllowAnonymous] +public class HomeController : Controller { - [SecurityHeaders] - [AllowAnonymous] - public class HomeController : Controller + private readonly IIdentityServerInteractionService _interaction; + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + public HomeController(IIdentityServerInteractionService interaction, IWebHostEnvironment environment, ILogger logger) { - private readonly IIdentityServerInteractionService _interaction; - private readonly IWebHostEnvironment _environment; - private readonly ILogger _logger; + _interaction = interaction; + _environment = environment; + _logger = logger; + } - public HomeController(IIdentityServerInteractionService interaction, IWebHostEnvironment environment, ILogger logger) + public IActionResult Index() + { + if (_environment.IsDevelopment()) { - _interaction = interaction; - _environment = environment; - _logger = logger; + // only show in development + return View(); } - public IActionResult Index() - { - if (_environment.IsDevelopment()) - { - // only show in development - return View(); - } + _logger.LogInformation("Homepage is disabled in production. Returning 404."); + return NotFound(); + } - _logger.LogInformation("Homepage is disabled in production. Returning 404."); - return NotFound(); - } + /// + /// Shows the error page + /// + public async Task Error(string errorId) + { + var vm = new ErrorViewModel(); - /// - /// Shows the error page - /// - public async Task Error(string errorId) + // retrieve error details from identityserver + var message = await _interaction.GetErrorContextAsync(errorId); + if (message != null) { - var vm = new ErrorViewModel(); + vm.Error = message; - // retrieve error details from identityserver - var message = await _interaction.GetErrorContextAsync(errorId); - if (message != null) + if (!_environment.IsDevelopment()) { - vm.Error = message; - - if (!_environment.IsDevelopment()) - { - // only show in development - message.ErrorDescription = null; - } + // only show in development + message.ErrorDescription = null; } - - return View("Error", vm); } + + return View("Error", vm); } -} \ No newline at end of file +} diff --git a/IdentityProvider/Quickstart/SecurityHeadersAttribute.cs b/IdentityProvider/Quickstart/SecurityHeadersAttribute.cs index e198d7f..a36c970 100644 --- a/IdentityProvider/Quickstart/SecurityHeadersAttribute.cs +++ b/IdentityProvider/Quickstart/SecurityHeadersAttribute.cs @@ -1,10 +1,6 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - namespace IdentityServerHost.Quickstart.UI { public class SecurityHeadersAttribute : ActionFilterAttribute diff --git a/IdentityProvider/Quickstart/TestUsers.cs b/IdentityProvider/Quickstart/TestUsers.cs index 00897fd..90afd9e 100644 --- a/IdentityProvider/Quickstart/TestUsers.cs +++ b/IdentityProvider/Quickstart/TestUsers.cs @@ -1,66 +1,57 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +namespace IdentityServerHost.Quickstart.UI; -using IdentityModel; -using System.Collections.Generic; -using System.Security.Claims; -using System.Text.Json; -using Duende.IdentityServer; -using Duende.IdentityServer.Test; - -namespace IdentityServerHost.Quickstart.UI +public class TestUsers { - public class TestUsers + public static List Users { - public static List Users + get { - get + var address = new { - var address = new - { - street_address = "One Hacker Way", - locality = "Heidelberg", - postal_code = 69118, - country = "Germany" - }; - - return new List + street_address = "One Hacker Way", + locality = "Heidelberg", + postal_code = 69118, + country = "Germany" + }; + + return new List + { + new TestUser { - new TestUser + SubjectId = "818727", + Username = "alice", + Password = "alice", + Claims = { - SubjectId = "818727", - Username = "alice", - Password = "alice", - Claims = - { - new Claim(JwtClaimTypes.Name, "Alice Smith"), - new Claim(JwtClaimTypes.GivenName, "Alice"), - new Claim(JwtClaimTypes.FamilyName, "Smith"), - new Claim(JwtClaimTypes.Email, "AliceSmith@email.com"), - new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), - new Claim(JwtClaimTypes.WebSite, "http://alice.com"), - new Claim(JwtClaimTypes.Address, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json) - } - }, - new TestUser + new Claim(JwtClaimTypes.Name, "Alice Smith"), + new Claim(JwtClaimTypes.GivenName, "Alice"), + new Claim(JwtClaimTypes.FamilyName, "Smith"), + new Claim(JwtClaimTypes.Email, "AliceSmith@email.com"), + new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), + new Claim(JwtClaimTypes.WebSite, "http://alice.com"), + new Claim(JwtClaimTypes.Address, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json) + } + }, + new TestUser + { + SubjectId = "88421113", + Username = "bob", + Password = "bob", + Claims = { - SubjectId = "88421113", - Username = "bob", - Password = "bob", - Claims = - { - new Claim(JwtClaimTypes.Name, "Bob Smith"), - new Claim(JwtClaimTypes.GivenName, "Bob"), - new Claim(JwtClaimTypes.FamilyName, "Smith"), - new Claim(JwtClaimTypes.Email, "BobSmith@email.com"), - new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), - new Claim(JwtClaimTypes.WebSite, "http://bob.com"), - new Claim(JwtClaimTypes.Address, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json) - } + new Claim(JwtClaimTypes.Name, "Bob Smith"), + new Claim(JwtClaimTypes.GivenName, "Bob"), + new Claim(JwtClaimTypes.FamilyName, "Smith"), + new Claim(JwtClaimTypes.Email, "BobSmith@email.com"), + new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), + new Claim(JwtClaimTypes.WebSite, "http://bob.com"), + new Claim(JwtClaimTypes.Address, JsonSerializer.Serialize(address), IdentityServerConstants.ClaimValueTypes.Json) } - }; - } + } + }; } } -} \ No newline at end of file +} diff --git a/IdentityProvider/Startup.cs b/IdentityProvider/Startup.cs index b599d76..b044744 100644 --- a/IdentityProvider/Startup.cs +++ b/IdentityProvider/Startup.cs @@ -1,13 +1,3 @@ -using System.Linq; -using System.Reflection; -using Duende.IdentityServer.EntityFramework.DbContexts; -using Duende.IdentityServer.EntityFramework.Mappers; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - namespace IdentityProvider { public class Startup @@ -17,7 +7,7 @@ public void ConfigureServices(IServiceCollection services) services.AddControllersWithViews(); // using local db (assumes Visual Studio has been installed) - const string connectionString = @"Data Source=(LocalDb)\MSSQLLocalDB;database=Test.IdentityServer.EntityFramework;trusted_connection=yes;"; + const string connectionString = @"server=localhost,1433;database=IdentityServer.Test;user id=sa;password=someStr0ng#Password;trusted_connection=yes;integrated security=false;"; var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name; services.AddDbContext(builder => diff --git a/IdentityProvider/Usings.cs b/IdentityProvider/Usings.cs new file mode 100644 index 0000000..a0b4db0 --- /dev/null +++ b/IdentityProvider/Usings.cs @@ -0,0 +1,37 @@ +global using IdentityProvider; +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.Extensions.Hosting; +global using System.Linq; +global using System.Reflection; +global using Duende.IdentityServer.EntityFramework.DbContexts; +global using Duende.IdentityServer.EntityFramework.Mappers; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Identity; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.DependencyInjection; +global using System.Collections.Generic; +global using System.Security.Claims; +global using Duende.IdentityServer; +global using Duende.IdentityServer.Models; +global using Duende.IdentityServer.Test; +global using IdentityModel; +global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +global using System.Text.Json; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Mvc.Filters; +global using System; +global using Microsoft.AspNetCore.Authentication; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Http; +global using System.Threading.Tasks; +global using Duende.IdentityServer.Events; +global using Duende.IdentityServer.Extensions; +global using Duende.IdentityServer.Services; +global using Duende.IdentityServer.Stores; +global using Microsoft.Extensions.Logging; +global using System.ComponentModel.DataAnnotations; +global using Duende.IdentityServer.Validation; +global using Duende.IdentityServer.Configuration; +global using Microsoft.Extensions.Options; +global using System.Text; +global using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; \ No newline at end of file diff --git a/Infrastructure/mssql-server-run.sh b/Infrastructure/mssql-server-run.sh new file mode 100644 index 0000000..4975b44 --- /dev/null +++ b/Infrastructure/mssql-server-run.sh @@ -0,0 +1,5 @@ +# MSSQL_SA_PASSWORD is the database system administrator (userid = 'sa') password used to connect to SQL Server once the container is running. +# Important note: This password needs to include at least 8 characters of at least three of these four categories: uppercase letters, +# lowercase letters, numbers and non-alphanumeric symbols. + +docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=someStr0ng#Password" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-CU1-ubuntu-20.04 \ No newline at end of file