diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffb9a39..ddaae6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ on: env: AZURE_WEBAPP_NAME: DuendeSoftware-Demo # set this to your application's name AZURE_WEBAPP_PACKAGE_PATH: './publish' # set this to the path to your web app project, defaults to the repository root + DOTNET_VERSION: '8.0.x' # set this to the dot net version to use jobs: build-and-deploy: @@ -28,6 +29,8 @@ jobs: # Setup .NET Core SDK - name: Setup .NET Core uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} # Run dotnet build and publish - name: dotnet build and publish diff --git a/src/DPoP/DPoPJwtBearerEvents.cs b/src/DPoP/DPoPJwtBearerEvents.cs index a701ed9..95bfb4c 100644 --- a/src/DPoP/DPoPJwtBearerEvents.cs +++ b/src/DPoP/DPoPJwtBearerEvents.cs @@ -1,6 +1,6 @@ -using ApiHost; using IdentityModel; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using System.Text; @@ -131,7 +131,7 @@ public override Task Challenge(JwtBearerChallengeContext context) } } - context.Response.Headers.Add(HeaderNames.WWWAuthenticate, sb.ToString()); + context.Response.Headers.Append(HeaderNames.WWWAuthenticate, sb.ToString()); if (context.HttpContext.Items.ContainsKey("DPoP-Nonce")) diff --git a/src/DPoP/DPoPProofValidatonContext.cs b/src/DPoP/DPoPProofValidatonContext.cs index c005ddf..cbd2186 100644 --- a/src/DPoP/DPoPProofValidatonContext.cs +++ b/src/DPoP/DPoPProofValidatonContext.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Security.Claims; namespace DPoPApi; diff --git a/src/DPoP/DPoPProofValidator.cs b/src/DPoP/DPoPProofValidator.cs index 7843de6..484a0a8 100644 --- a/src/DPoP/DPoPProofValidator.cs +++ b/src/DPoP/DPoPProofValidator.cs @@ -1,4 +1,3 @@ -using DPoPApi; using IdentityModel; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; @@ -13,7 +12,7 @@ using System.Text.Json; using System.Threading.Tasks; -namespace ApiHost; +namespace DPoPApi; public class DPoPProofValidator { @@ -83,12 +82,12 @@ public async Task ValidateAsync(DPoPProofValidatonCont return result; } - Logger.LogDebug("Successfully validated DPoP proof token"); + Logger.LogDebug("Successfully validated DPoP proof token with thumbprint: {jkt}", result.JsonWebKeyThumbprint); result.IsError = false; } finally { - if (result.IsError) + if (result.IsError && String.IsNullOrWhiteSpace(result.Error)) { result.Error = OidcConstants.TokenErrors.InvalidDPoPProof; } @@ -117,21 +116,21 @@ protected virtual Task ValidateHeaderAsync(DPoPProofValidatonContext context, DP return Task.CompletedTask; } - if (!token.TryGetHeaderValue("typ", out var typ) || typ != JwtClaimTypes.JwtTypes.DPoPProofToken) + if (!token.TryGetHeaderValue(JwtClaimTypes.TokenType, out var typ) || typ != JwtClaimTypes.JwtTypes.DPoPProofToken) { result.IsError = true; result.ErrorDescription = "Invalid 'typ' value."; return Task.CompletedTask; } - if (!token.TryGetHeaderValue("alg", out var alg) || !SupportedDPoPSigningAlgorithms.Contains(alg)) + if (!token.TryGetHeaderValue(JwtClaimTypes.Algorithm, out var alg) || !SupportedDPoPSigningAlgorithms.Contains(alg)) { result.IsError = true; result.ErrorDescription = "Invalid 'alg' value."; return Task.CompletedTask; } - if (!token.TryGetHeaderValue>(JwtClaimTypes.JsonWebKey, out var jwkValues)) + if (!token.TryGetHeaderValue(JwtClaimTypes.JsonWebKey, out var jwkValues)) { result.IsError = true; result.ErrorDescription = "Invalid 'jwk' value."; @@ -170,7 +169,7 @@ protected virtual Task ValidateHeaderAsync(DPoPProofValidatonContext context, DP /// /// Validates the signature. /// - protected virtual Task ValidateSignatureAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) + protected virtual async Task ValidateSignatureAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result) { TokenValidationResult tokenValidationResult; @@ -186,14 +185,14 @@ protected virtual Task ValidateSignatureAsync(DPoPProofValidatonContext context, }; var handler = new JsonWebTokenHandler(); - tokenValidationResult = handler.ValidateToken(context.ProofToken, tvp); + tokenValidationResult = await handler.ValidateTokenAsync(context.ProofToken, tvp); } catch (Exception ex) { Logger.LogDebug("Error parsing DPoP token: {error}", ex.Message); result.IsError = true; result.ErrorDescription = "Invalid signature on DPoP token."; - return Task.CompletedTask; + return; } if (tokenValidationResult.Exception != null) @@ -201,12 +200,10 @@ protected virtual Task ValidateSignatureAsync(DPoPProofValidatonContext context, Logger.LogDebug("Error parsing DPoP token: {error}", tokenValidationResult.Exception.Message); result.IsError = true; result.ErrorDescription = "Invalid signature on DPoP token."; - return Task.CompletedTask; + return; } result.Payload = tokenValidationResult.Claims; - - return Task.CompletedTask; } /// @@ -270,11 +267,11 @@ protected virtual async Task ValidatePayloadAsync(DPoPProofValidatonContext cont { if (iat is int) { - result.IssuedAt = (int) iat; + result.IssuedAt = (int)iat; } if (iat is long) { - result.IssuedAt = (long) iat; + result.IssuedAt = (long)iat; } } @@ -337,6 +334,9 @@ protected virtual async Task ValidateReplayAsync(DPoPProofValidatonContext conte // longer than the likelyhood of proof token expiration, which is done before replay skew *= 2; var cacheDuration = dpopOptions.ProofTokenValidityDuration + skew; + + Logger.LogDebug("Adding proof token with jti {jti} to replay cache for duration {cacheDuration}", result.TokenId, cacheDuration); + await ReplayCache.AddAsync(ReplayCachePurpose, result.TokenId, DateTimeOffset.UtcNow.Add(cacheDuration)); } @@ -447,11 +447,11 @@ protected virtual ValueTask GetUnixTimeFromNonceAsync(DPoPProofValidatonCo return ValueTask.FromResult(iat); } } - catch (Exception ex) + catch(Exception ex) { Logger.LogDebug("Error parsing DPoP 'nonce' value: {error}", ex.ToString()); } - + return ValueTask.FromResult(0); } diff --git a/src/DPoP/DPoPServiceCollectionExtensions.cs b/src/DPoP/DPoPServiceCollectionExtensions.cs index f0049d4..a0564da 100644 --- a/src/DPoP/DPoPServiceCollectionExtensions.cs +++ b/src/DPoP/DPoPServiceCollectionExtensions.cs @@ -1,4 +1,3 @@ -using ApiHost; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; diff --git a/src/Duende.IdentityServer.Demo.csproj b/src/Duende.IdentityServer.Demo.csproj index 9b03709..8739e16 100644 --- a/src/Duende.IdentityServer.Demo.csproj +++ b/src/Duende.IdentityServer.Demo.csproj @@ -1,13 +1,13 @@  - net6.0 + net8.0 enable - - - + + + diff --git a/src/Pages/Account/AccessDenied.cshtml.cs b/src/Pages/Account/AccessDenied.cshtml.cs index a43f356..efd83a9 100644 --- a/src/Pages/Account/AccessDenied.cshtml.cs +++ b/src/Pages/Account/AccessDenied.cshtml.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Mvc; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Microsoft.AspNetCore.Mvc.RazorPages; namespace IdentityServerHost.Pages.Account; @@ -8,4 +10,4 @@ public class AccessDeniedModel : PageModel public void OnGet() { } -} \ No newline at end of file +} diff --git a/src/Pages/Account/Create/Index.cshtml b/src/Pages/Account/Create/Index.cshtml new file mode 100644 index 0000000..9c65baf --- /dev/null +++ b/src/Pages/Account/Create/Index.cshtml @@ -0,0 +1,40 @@ +@page +@model IdentityServerHost.Pages.Create.Index + + \ No newline at end of file diff --git a/src/Pages/Account/Create/Index.cshtml.cs b/src/Pages/Account/Create/Index.cshtml.cs new file mode 100644 index 0000000..e8f0093 --- /dev/null +++ b/src/Pages/Account/Create/Index.cshtml.cs @@ -0,0 +1,121 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Test; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentityServerHost.Pages.Create; + +[SecurityHeaders] +[AllowAnonymous] +public class Index : PageModel +{ + private readonly TestUserStore _users; + private readonly IIdentityServerInteractionService _interaction; + + [BindProperty] + public InputModel Input { get; set; } = default!; + + public Index( + IIdentityServerInteractionService interaction, + TestUserStore? users = null) + { + // this is where you would plug in your own custom identity management library (e.g. ASP.NET Identity) + _users = users ?? throw new InvalidOperationException("Please call 'AddTestUsers(TestUsers.Users)' on the IIdentityServerBuilder in Startup or remove the TestUserStore from the AccountController."); + + _interaction = interaction; + } + + public IActionResult OnGet(string? returnUrl) + { + Input = new InputModel { ReturnUrl = returnUrl }; + return Page(); + } + + public async Task OnPost() + { + // check if we are in the context of an authorization request + var context = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl); + + // the user clicked the "cancel" button + if (Input.Button != "create") + { + 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(Input.ReturnUrl); + } + + return Redirect(Input.ReturnUrl ?? "~/"); + } + else + { + // since we don't have a valid context, then we just go back to the home page + return Redirect("~/"); + } + } + + if (_users.FindByUsername(Input.Username) != null) + { + ModelState.AddModelError("Input.Username", "Invalid username"); + } + + if (ModelState.IsValid) + { + var user = _users.CreateUser(Input.Username, Input.Password, Input.Name, Input.Email); + + // issue authentication cookie with subject ID and username + var isuser = new IdentityServerUser(user.SubjectId) + { + DisplayName = user.Username + }; + + await HttpContext.SignInAsync(isuser); + + 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(Input.ReturnUrl); + } + + // we can trust Input.ReturnUrl since GetAuthorizationContextAsync returned non-null + return Redirect(Input.ReturnUrl ?? "~/"); + } + + // request for a local page + if (Url.IsLocalUrl(Input.ReturnUrl)) + { + return Redirect(Input.ReturnUrl); + } + else if (string.IsNullOrEmpty(Input.ReturnUrl)) + { + return Redirect("~/"); + } + else + { + // user might have clicked on a malicious link - should be logged + throw new ArgumentException("invalid return URL"); + } + } + + return Page(); + } +} diff --git a/src/Pages/Account/Create/InputModel.cs b/src/Pages/Account/Create/InputModel.cs new file mode 100644 index 0000000..14c46eb --- /dev/null +++ b/src/Pages/Account/Create/InputModel.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.ComponentModel.DataAnnotations; + +namespace IdentityServerHost.Pages.Create; + +public class InputModel +{ + [Required] + public string? Username { get; set; } + + [Required] + public string? Password { get; set; } + + public string? Name { get; set; } + public string? Email { get; set; } + + public string? ReturnUrl { get; set; } + + public string? Button { get; set; } +} \ No newline at end of file diff --git a/src/Pages/Account/Login/Index.cshtml.cs b/src/Pages/Account/Login/Index.cshtml.cs index 22f92fd..2a0bf8b 100644 --- a/src/Pages/Account/Login/Index.cshtml.cs +++ b/src/Pages/Account/Login/Index.cshtml.cs @@ -1,6 +1,6 @@ -using System; -using System.Linq; -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer; using Duende.IdentityServer.Events; using Duende.IdentityServer.Models; @@ -9,7 +9,6 @@ using Duende.IdentityServer.Test; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -21,42 +20,39 @@ public class Index : PageModel { private readonly TestUserStore _users; private readonly IIdentityServerInteractionService _interaction; - private readonly IClientStore _clientStore; private readonly IEventService _events; private readonly IAuthenticationSchemeProvider _schemeProvider; private readonly IIdentityProviderStore _identityProviderStore; - public ViewModel View { get; set; } - + public ViewModel View { get; set; } = default!; + [BindProperty] - public InputModel Input { get; set; } - + public InputModel Input { get; set; } = default!; + public Index( IIdentityServerInteractionService interaction, - IClientStore clientStore, IAuthenticationSchemeProvider schemeProvider, IIdentityProviderStore identityProviderStore, IEventService events, - TestUserStore users = null) + TestUserStore? users = null) { // this is where you would plug in your own custom identity management library (e.g. ASP.NET Identity) - _users = users ?? throw new Exception("Please call 'AddTestUsers(TestUsers.Users)' on the IIdentityServerBuilder in Startup or remove the TestUserStore from the AccountController."); + _users = users ?? throw new InvalidOperationException("Please call 'AddTestUsers(TestUsers.Users)' on the IIdentityServerBuilder in Startup or remove the TestUserStore from the AccountController."); _interaction = interaction; - _clientStore = clientStore; _schemeProvider = schemeProvider; _identityProviderStore = identityProviderStore; _events = events; } - - public async Task OnGet(string returnUrl) + + public async Task OnGet(string? returnUrl) { await BuildModelAsync(returnUrl); if (View.IsExternalLoginOnly) { // we only have one option for logging in and it's an external provider - return RedirectToPage("/ExternalLogin/Challenge/Index", new { scheme = View.ExternalLoginScheme, returnUrl }); + return RedirectToPage("/ExternalLogin/Challenge", new { scheme = View.ExternalLoginScheme, returnUrl }); } return Page(); @@ -72,6 +68,9 @@ public async Task OnPost() { if (context != null) { + // This "can't happen", because if the ReturnUrl was null, then the context would be null + ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl)); + // 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. @@ -85,7 +84,7 @@ public async Task OnPost() return this.LoadingPage(Input.ReturnUrl); } - return Redirect(Input.ReturnUrl); + return Redirect(Input.ReturnUrl ?? "~/"); } else { @@ -101,17 +100,15 @@ public async Task OnPost() { var user = _users.FindByUsername(Input.Username); await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username, clientId: context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider); // only set explicit expiration here if user chooses "remember me". // otherwise we rely upon expiration configured in cookie middleware. - AuthenticationProperties props = null; + var props = new AuthenticationProperties(); if (LoginOptions.AllowRememberLogin && Input.RememberLogin) { - props = new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.Add(LoginOptions.RememberMeLoginDuration) - }; + props.IsPersistent = true; + props.ExpiresUtc = DateTimeOffset.UtcNow.Add(LoginOptions.RememberMeLoginDuration); }; // issue authentication cookie with subject ID and username @@ -124,6 +121,9 @@ public async Task OnPost() if (context != null) { + // This "can't happen", because if the ReturnUrl was null, then the context would be null + ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl)); + if (context.IsNativeClient()) { // The client is native, so this change in how to @@ -132,7 +132,7 @@ public async Task OnPost() } // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null - return Redirect(Input.ReturnUrl); + return Redirect(Input.ReturnUrl ?? "~/"); } // request for a local page @@ -147,11 +147,13 @@ public async Task OnPost() else { // user might have clicked on a malicious link - should be logged - throw new Exception("invalid return URL"); + throw new ArgumentException("invalid return URL"); } } - await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, "invalid credentials", clientId:context?.Client.ClientId)); + const string error = "invalid credentials"; + await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId)); + Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error); ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage); } @@ -159,8 +161,8 @@ public async Task OnPost() await BuildModelAsync(Input.ReturnUrl); return Page(); } - - private async Task BuildModelAsync(string returnUrl) + + private async Task BuildModelAsync(string? returnUrl) { Input = new InputModel { @@ -178,12 +180,14 @@ private async Task BuildModelAsync(string returnUrl) EnableLocalLogin = local, }; - Input.Username = context?.LoginHint; + Input.Username = context.LoginHint; if (!local) { - View.ExternalProviders = new[] { new ViewModel.ExternalProvider { AuthenticationScheme = context.IdP } }; + View.ExternalProviders = new[] { new ViewModel.ExternalProvider ( authenticationScheme: context.IdP ) }; } + + return; } var schemes = await _schemeProvider.GetAllSchemesAsync(); @@ -191,33 +195,29 @@ private async Task BuildModelAsync(string returnUrl) var providers = schemes .Where(x => x.DisplayName != null) .Select(x => new ViewModel.ExternalProvider - { - DisplayName = x.DisplayName ?? x.Name, - AuthenticationScheme = x.Name - }).ToList(); + ( + authenticationScheme: x.Name, + displayName: x.DisplayName ?? x.Name + )).ToList(); - var dyanmicSchemes = (await _identityProviderStore.GetAllSchemeNamesAsync()) + var dynamicSchemes = (await _identityProviderStore.GetAllSchemeNamesAsync()) .Where(x => x.Enabled) .Select(x => new ViewModel.ExternalProvider - { - AuthenticationScheme = x.Scheme, - DisplayName = x.DisplayName - }); - providers.AddRange(dyanmicSchemes); + ( + authenticationScheme: x.Scheme, + displayName: x.DisplayName ?? x.Scheme + )); + providers.AddRange(dynamicSchemes); var allowLocal = true; - if (context?.Client.ClientId != null) + var client = context?.Client; + if (client != null) { - var client = await _clientStore.FindEnabledClientByIdAsync(context.Client.ClientId); - if (client != null) + allowLocal = client.EnableLocalLogin; + if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Count != 0) { - allowLocal = client.EnableLocalLogin; - - if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Any()) - { - providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList(); - } + providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList(); } } @@ -228,4 +228,4 @@ private async Task BuildModelAsync(string returnUrl) ExternalProviders = providers.ToArray() }; } -} \ No newline at end of file +} diff --git a/src/Pages/Account/Login/InputModel.cs b/src/Pages/Account/Login/InputModel.cs index de20f0d..9a25a8e 100644 --- a/src/Pages/Account/Login/InputModel.cs +++ b/src/Pages/Account/Login/InputModel.cs @@ -1,7 +1,6 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - using System.ComponentModel.DataAnnotations; namespace IdentityServerHost.Pages.Login; @@ -9,14 +8,10 @@ namespace IdentityServerHost.Pages.Login; public class InputModel { [Required] - public string Username { get; set; } - + public string? Username { get; set; } [Required] - public string Password { get; set; } - + public string? Password { get; set; } public bool RememberLogin { get; set; } - - public string ReturnUrl { get; set; } - - public string Button { get; set; } + public string? ReturnUrl { get; set; } + public string? Button { get; set; } } \ No newline at end of file diff --git a/src/Pages/Account/Login/LoginOptions.cs b/src/Pages/Account/Login/LoginOptions.cs index 86f879a..0a29e70 100644 --- a/src/Pages/Account/Login/LoginOptions.cs +++ b/src/Pages/Account/Login/LoginOptions.cs @@ -1,11 +1,12 @@ -using System; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. namespace IdentityServerHost.Pages.Login; -public class LoginOptions +public static class LoginOptions { - public static bool AllowLocalLogin = true; - public static bool AllowRememberLogin = true; - public static TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30); - public static string InvalidCredentialsErrorMessage = "Invalid username or password"; -} \ No newline at end of file + public static readonly bool AllowLocalLogin = true; + public static readonly bool AllowRememberLogin = true; + public static readonly TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30); + public static readonly string InvalidCredentialsErrorMessage = "Invalid username or password"; +} diff --git a/src/Pages/Account/Login/ViewModel.cs b/src/Pages/Account/Login/ViewModel.cs index ac8eead..d47938c 100644 --- a/src/Pages/Account/Login/ViewModel.cs +++ b/src/Pages/Account/Login/ViewModel.cs @@ -1,11 +1,6 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; - namespace IdentityServerHost.Pages.Login; public class ViewModel @@ -17,11 +12,17 @@ public class ViewModel 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; + public string? ExternalLoginScheme => IsExternalLoginOnly ? ExternalProviders?.SingleOrDefault()?.AuthenticationScheme : null; public class ExternalProvider { - public string DisplayName { get; set; } + public ExternalProvider(string authenticationScheme, string? displayName = null) + { + AuthenticationScheme = authenticationScheme; + DisplayName = displayName; + } + + public string? DisplayName { get; set; } public string AuthenticationScheme { get; set; } } } \ No newline at end of file diff --git a/src/Pages/Account/Logout/Index.cshtml.cs b/src/Pages/Account/Logout/Index.cshtml.cs index 5d5841c..aef1de3 100644 --- a/src/Pages/Account/Logout/Index.cshtml.cs +++ b/src/Pages/Account/Logout/Index.cshtml.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer.Events; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Services; @@ -18,7 +20,7 @@ public class Index : PageModel private readonly IEventService _events; [BindProperty] - public string LogoutId { get; set; } + public string? LogoutId { get; set; } public Index(IIdentityServerInteractionService interaction, IEventService events) { @@ -26,13 +28,13 @@ public Index(IIdentityServerInteractionService interaction, IEventService events _events = events; } - public async Task OnGet(string logoutId) + public async Task OnGet(string? logoutId) { LogoutId = logoutId; var showLogoutPrompt = LogoutOptions.ShowLogoutPrompt; - if (User?.Identity.IsAuthenticated != true) + if (User.Identity?.IsAuthenticated != true) { // if the user is not authenticated, then just show logged out page showLogoutPrompt = false; @@ -59,7 +61,7 @@ public async Task OnGet(string logoutId) public async Task OnPost() { - if (User?.Identity.IsAuthenticated == true) + if (User.Identity?.IsAuthenticated == true) { // if there's no current logout context, we need to create one // this captures necessary info from the current logged in user @@ -69,12 +71,13 @@ public async Task OnPost() // delete local authentication cookie await HttpContext.SignOutAsync(); - // raise the logout event - await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); - // see if we need to trigger federated logout var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; + // raise the logout event + await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); + Telemetry.Metrics.UserLogout(idp); + // if it's a local login we can ignore this workflow if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) { @@ -84,7 +87,7 @@ public async Task OnPost() // 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.Page("/Account/Logout/Loggedout", new { logoutId = LogoutId }); + var url = Url.Page("/Account/Logout/Loggedout", new { logoutId = LogoutId }); // this triggers a redirect to the external provider for sign-out return SignOut(new AuthenticationProperties { RedirectUri = url }, idp); @@ -94,4 +97,4 @@ public async Task OnPost() return RedirectToPage("/Account/Logout/LoggedOut", new { logoutId = LogoutId }); } -} \ No newline at end of file +} diff --git a/src/Pages/Account/Logout/LoggedOut.cshtml.cs b/src/Pages/Account/Logout/LoggedOut.cshtml.cs index 2e1957c..ba28a8d 100644 --- a/src/Pages/Account/Logout/LoggedOut.cshtml.cs +++ b/src/Pages/Account/Logout/LoggedOut.cshtml.cs @@ -1,5 +1,6 @@ -using System; -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -11,15 +12,15 @@ namespace IdentityServerHost.Pages.Logout; public class LoggedOut : PageModel { private readonly IIdentityServerInteractionService _interactionService; - - public LoggedOutViewModel View { get; set; } + + public LoggedOutViewModel View { get; set; } = default!; public LoggedOut(IIdentityServerInteractionService interactionService) { _interactionService = interactionService; } - public async Task OnGet(string logoutId) + public async Task OnGet(string? logoutId) { // get context information (client name, post logout redirect URI and iframe for federated signout) var logout = await _interactionService.GetLogoutContextAsync(logoutId); @@ -32,4 +33,4 @@ public async Task OnGet(string logoutId) SignOutIframeUrl = logout?.SignOutIFrameUrl }; } -} \ No newline at end of file +} diff --git a/src/Pages/Account/Logout/LoggedOutViewModel.cs b/src/Pages/Account/Logout/LoggedOutViewModel.cs index 77b1ef0..19a5d97 100644 --- a/src/Pages/Account/Logout/LoggedOutViewModel.cs +++ b/src/Pages/Account/Logout/LoggedOutViewModel.cs @@ -1,14 +1,15 @@ - // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. namespace IdentityServerHost.Pages.Logout; 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; } -} \ No newline at end of file +} diff --git a/src/Pages/Account/Logout/LogoutOptions.cs b/src/Pages/Account/Logout/LogoutOptions.cs index d93a852..58d3775 100644 --- a/src/Pages/Account/Logout/LogoutOptions.cs +++ b/src/Pages/Account/Logout/LogoutOptions.cs @@ -1,8 +1,11 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + namespace IdentityServerHost.Pages.Logout; -public class LogoutOptions +public static class LogoutOptions { - public static bool ShowLogoutPrompt = true; - public static bool AutomaticRedirectAfterSignOut = false; -} \ No newline at end of file + public static readonly bool ShowLogoutPrompt = true; + public static readonly bool AutomaticRedirectAfterSignOut = false; +} diff --git a/src/Pages/Ciba/All.cshtml b/src/Pages/Ciba/All.cshtml index 30ecd6f..34579f5 100644 --- a/src/Pages/Ciba/All.cshtml +++ b/src/Pages/Ciba/All.cshtml @@ -11,7 +11,7 @@

Pending Backchannel Login Requests

- @if (Model.Logins?.Any() == true) + @if (Model.Logins.Any()) { diff --git a/src/Pages/Ciba/All.cshtml.cs b/src/Pages/Ciba/All.cshtml.cs index 7a36b71..c8f65b5 100644 --- a/src/Pages/Ciba/All.cshtml.cs +++ b/src/Pages/Ciba/All.cshtml.cs @@ -1,14 +1,9 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Threading.Tasks; -using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; namespace IdentityServerHost.Pages.Ciba; @@ -17,12 +12,7 @@ namespace IdentityServerHost.Pages.Ciba; [Authorize] public class AllModel : PageModel { - public IEnumerable Logins { get; set; } - - [BindProperty, Required] - public string Id { get; set; } - [BindProperty, Required] - public string Button { get; set; } + public IEnumerable Logins { get; set; } = default!; private readonly IBackchannelAuthenticationInteractionService _backchannelAuthenticationInteraction; diff --git a/src/Pages/Ciba/Consent.cshtml.cs b/src/Pages/Ciba/Consent.cshtml.cs index 71da9ec..2261356 100644 --- a/src/Pages/Ciba/Consent.cshtml.cs +++ b/src/Pages/Ciba/Consent.cshtml.cs @@ -1,7 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer.Events; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; @@ -10,37 +9,35 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Logging; namespace IdentityServerHost.Pages.Ciba; [Authorize] -[SecurityHeadersAttribute] +[SecurityHeaders] public class Consent : PageModel { private readonly IBackchannelAuthenticationInteractionService _interaction; private readonly IEventService _events; - private readonly ILogger _logger; + private readonly ILogger _logger; public Consent( IBackchannelAuthenticationInteractionService interaction, IEventService events, - ILogger logger) + ILogger logger) { _interaction = interaction; _events = events; _logger = logger; } - public ViewModel View { get; set; } - + public ViewModel View { get; set; } = default!; + [BindProperty] - public InputModel Input { get; set; } + public InputModel Input { get; set; } = default!; - public async Task OnGet(string id) + public async Task OnGet(string? id) { - View = await BuildViewModelAsync(id); - if (View == null) + if (!await SetViewModelAsync(id)) { return RedirectToPage("/Home/Error/Index"); } @@ -56,28 +53,29 @@ public async Task OnGet(string id) public async Task OnPost() { // validate return url is still valid - var request = await _interaction.GetLoginRequestByInternalIdAsync(Input.Id); + var request = await _interaction.GetLoginRequestByInternalIdAsync(Input.Id ?? throw new ArgumentNullException(nameof(Input.Id))); if (request == null || request.Subject.GetSubjectId() != User.GetSubjectId()) { - _logger.LogError("Invalid id {id}", Input.Id); + _logger.InvalidId(Input.Id); return RedirectToPage("/Home/Error/Index"); } - CompleteBackchannelLoginRequest result = null; + CompleteBackchannelLoginRequest? result = null; // user clicked 'no' - send back the standard 'access_denied' response - if (Input?.Button == "no") + if (Input.Button == "no") { result = new CompleteBackchannelLoginRequest(Input.Id); // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data - else if (Input?.Button == "yes") + else if (Input.Button == "yes") { // if the user consented to some scope, build the response model - if (Input.ScopesConsented != null && Input.ScopesConsented.Any()) + if (Input.ScopesConsented.Any()) { var scopes = Input.ScopesConsented; if (ConsentOptions.EnableOfflineAccess == false) @@ -93,6 +91,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, result.ScopesValuesConsented, false)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, result.ScopesValuesConsented, false); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(result.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { @@ -113,27 +114,31 @@ public async Task OnPost() } // we need to redisplay the consent UI - View = await BuildViewModelAsync(Input.Id, Input); + if (!await SetViewModelAsync(Input.Id)) + { + return RedirectToPage("/Home/Error/Index"); + } return Page(); } - private async Task BuildViewModelAsync(string id, InputModel model = null) + private async Task SetViewModelAsync(string? id) { + ArgumentNullException.ThrowIfNull(id); + var request = await _interaction.GetLoginRequestByInternalIdAsync(id); if (request != null && request.Subject.GetSubjectId() == User.GetSubjectId()) { - return CreateConsentViewModel(model, id, request); + View = CreateConsentViewModel(request); + return true; } else { - _logger.LogError("No backchannel login request matching id: {id}", id); + _logger.NoMatchingBackchannelLoginRequest(id); + return false; } - return null; } - private ViewModel CreateConsentViewModel( - InputModel model, string id, - BackchannelUserLoginRequest request) + private ViewModel CreateConsentViewModel(BackchannelUserLoginRequest request) { var vm = new ViewModel { @@ -144,7 +149,7 @@ private ViewModel CreateConsentViewModel( }; vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources - .Select(x => CreateScopeViewModel(x, model?.ScopesConsented == null || model.ScopesConsented?.Contains(x.Name) == true)) + .Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name))) .ToArray(); var resourceIndicators = request.RequestedResourceIndicators ?? Enumerable.Empty(); @@ -156,7 +161,7 @@ private ViewModel CreateConsentViewModel( var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); if (apiScope != null) { - var scopeVm = CreateScopeViewModel(parsedScope, apiScope, model == null || model.ScopesConsented?.Contains(parsedScope.RawValue) == true); + var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue)); scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName)) .Select(x => new ResourceViewModel { @@ -168,14 +173,14 @@ private ViewModel CreateConsentViewModel( } if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) { - apiScopes.Add(GetOfflineAccessScope(model == null || model.ScopesConsented?.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess) == true)); + apiScopes.Add(GetOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess))); } vm.ApiScopes = apiScopes; return vm; } - private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) { return new ScopeViewModel { @@ -189,7 +194,7 @@ private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool chec }; } - public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) { var displayName = apiScope.DisplayName ?? apiScope.Name; if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter)) @@ -209,7 +214,7 @@ public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, Ap }; } - private ScopeViewModel GetOfflineAccessScope(bool check) + private static ScopeViewModel GetOfflineAccessScope(bool check) { return new ScopeViewModel { @@ -220,4 +225,4 @@ private ScopeViewModel GetOfflineAccessScope(bool check) Checked = check }; } -} \ No newline at end of file +} diff --git a/src/Pages/Ciba/ConsentOptions.cs b/src/Pages/Ciba/ConsentOptions.cs index adca0eb..880775d 100644 --- a/src/Pages/Ciba/ConsentOptions.cs +++ b/src/Pages/Ciba/ConsentOptions.cs @@ -1,14 +1,13 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - namespace IdentityServerHost.Pages.Ciba; -public class ConsentOptions +public static 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 readonly bool EnableOfflineAccess = true; + public static readonly string OfflineAccessDisplayName = "Offline Access"; + public static readonly 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"; diff --git a/src/Pages/Ciba/Index.cshtml.cs b/src/Pages/Ciba/Index.cshtml.cs index 3e215cb..f180fab 100644 --- a/src/Pages/Ciba/Index.cshtml.cs +++ b/src/Pages/Ciba/Index.cshtml.cs @@ -1,13 +1,11 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. -using System.Threading.Tasks; using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Logging; namespace IdentityServerHost.Pages.Ciba; @@ -15,7 +13,7 @@ namespace IdentityServerHost.Pages.Ciba; [SecurityHeaders] public class IndexModel : PageModel { - public BackchannelUserLoginRequest LoginRequest { get; set; } + public BackchannelUserLoginRequest LoginRequest { get; set; } = default!; private readonly IBackchannelAuthenticationInteractionService _backchannelAuthenticationInteraction; private readonly ILogger _logger; @@ -28,13 +26,17 @@ public IndexModel(IBackchannelAuthenticationInteractionService backchannelAuthen public async Task OnGet(string id) { - LoginRequest = await _backchannelAuthenticationInteraction.GetLoginRequestByInternalIdAsync(id); - if (LoginRequest == null) + var result = await _backchannelAuthenticationInteraction.GetLoginRequestByInternalIdAsync(id); + if (result == null) { - _logger.LogWarning("Invalid backchannel login id {id}", id); - return RedirectToPage("/home/error/index"); + _logger.InvalidBackchannelLoginId(id); + return RedirectToPage("/Home/Error/Index"); } - + else + { + LoginRequest = result; + } + return Page(); } } \ No newline at end of file diff --git a/src/Pages/Ciba/InputModel.cs b/src/Pages/Ciba/InputModel.cs index b3ae21a..d7e05b3 100644 --- a/src/Pages/Ciba/InputModel.cs +++ b/src/Pages/Ciba/InputModel.cs @@ -1,15 +1,12 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using System.Collections.Generic; - namespace IdentityServerHost.Pages.Ciba; public class InputModel { - public string Button { get; set; } - public IEnumerable ScopesConsented { get; set; } - public string Id { get; set; } - public string Description { get; set; } + public string? Button { get; set; } + public IEnumerable ScopesConsented { get; set; } = new List(); + public string? Id { get; set; } + public string? Description { get; set; } } \ No newline at end of file diff --git a/src/Pages/Ciba/ViewModel.cs b/src/Pages/Ciba/ViewModel.cs index 54d0445..c0a796c 100644 --- a/src/Pages/Ciba/ViewModel.cs +++ b/src/Pages/Ciba/ViewModel.cs @@ -1,36 +1,34 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. -using System.Collections.Generic; - namespace IdentityServerHost.Pages.Ciba; public class ViewModel { - public string ClientName { get; set; } - public string ClientUrl { get; set; } - public string ClientLogoUrl { get; set; } + public string? ClientName { get; set; } + public string? ClientUrl { get; set; } + public string? ClientLogoUrl { get; set; } - public string BindingMessage { get; set; } + public string? BindingMessage { get; set; } - public IEnumerable IdentityScopes { get; set; } - public IEnumerable ApiScopes { get; set; } + public IEnumerable IdentityScopes { get; set; } = Enumerable.Empty(); + public IEnumerable ApiScopes { get; set; } = Enumerable.Empty(); } public class ScopeViewModel { - public string Name { get; set; } - public string Value { get; set; } - public string DisplayName { get; set; } - public string Description { 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; } + public IEnumerable Resources { get; set; } = Enumerable.Empty(); } public class ResourceViewModel { - public string Name { get; set; } - public string DisplayName { get; set; } + public string? Name { get; set; } + public string? DisplayName { get; set; } } \ No newline at end of file diff --git a/src/Pages/Consent/ConsentOptions.cs b/src/Pages/Consent/ConsentOptions.cs index ad78a1f..b3f8aa7 100644 --- a/src/Pages/Consent/ConsentOptions.cs +++ b/src/Pages/Consent/ConsentOptions.cs @@ -1,14 +1,13 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - namespace IdentityServerHost.Pages.Consent; -public class ConsentOptions +public static 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 readonly bool EnableOfflineAccess = true; + public static readonly string OfflineAccessDisplayName = "Offline Access"; + public static readonly 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"; diff --git a/src/Pages/Consent/Index.cshtml.cs b/src/Pages/Consent/Index.cshtml.cs index a743e71..e6daedc 100644 --- a/src/Pages/Consent/Index.cshtml.cs +++ b/src/Pages/Consent/Index.cshtml.cs @@ -1,7 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer.Events; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; @@ -11,12 +10,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Logging; namespace IdentityServerHost.Pages.Consent; [Authorize] -[SecurityHeadersAttribute] +[SecurityHeaders] public class Index : PageModel { private readonly IIdentityServerInteractionService _interaction; @@ -33,17 +31,16 @@ public Index( _logger = logger; } - public ViewModel View { get; set; } - + public ViewModel View { get; set; } = default!; + [BindProperty] - public InputModel Input { get; set; } + public InputModel Input { get; set; } = default!; - public async Task OnGet(string returnUrl) + public async Task OnGet(string? returnUrl) { - View = await BuildViewModelAsync(returnUrl); - if (View == null) + if (!await SetViewModelAsync(returnUrl)) { - return RedirectToPage("/Error/Index"); + return RedirectToPage("/Home/Error/Index"); } Input = new InputModel @@ -58,23 +55,24 @@ public async Task OnPost() { // validate return url is still valid var request = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl); - if (request == null) return RedirectToPage("/Error/Index"); + if (request == null) return RedirectToPage("/Home/Error/Index"); - ConsentResponse grantedConsent = null; + ConsentResponse? grantedConsent = null; // user clicked 'no' - send back the standard 'access_denied' response - if (Input?.Button == "no") + if (Input.Button == "no") { grantedConsent = new ConsentResponse { Error = AuthorizationError.AccessDenied }; // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data - else if (Input?.Button == "yes") + else if (Input.Button == "yes") { // if the user consented to some scope, build the response model - if (Input.ScopesConsented != null && Input.ScopesConsented.Any()) + if (Input.ScopesConsented.Any()) { var scopes = Input.ScopesConsented; if (ConsentOptions.EnableOfflineAccess == false) @@ -91,6 +89,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { @@ -104,6 +105,8 @@ public async Task OnPost() if (grantedConsent != null) { + ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl)); + // communicate outcome of consent back to identityserver await _interaction.GrantConsentAsync(request, grantedConsent); @@ -119,27 +122,31 @@ public async Task OnPost() } // we need to redisplay the consent UI - View = await BuildViewModelAsync(Input.ReturnUrl, Input); + if (!await SetViewModelAsync(Input.ReturnUrl)) + { + return RedirectToPage("/Home/Error/Index"); + } return Page(); } - private async Task BuildViewModelAsync(string returnUrl, InputModel model = null) + private async Task SetViewModelAsync(string? returnUrl) { + ArgumentNullException.ThrowIfNull(returnUrl); + var request = await _interaction.GetAuthorizationContextAsync(returnUrl); if (request != null) { - return CreateConsentViewModel(model, returnUrl, request); + View = CreateConsentViewModel(request); + return true; } else { - _logger.LogError("No consent request matching request: {0}", returnUrl); + _logger.NoConsentMatchingRequest(returnUrl); + return false; } - return null; } - private ViewModel CreateConsentViewModel( - InputModel model, string returnUrl, - AuthorizationRequest request) + private ViewModel CreateConsentViewModel(AuthorizationRequest request) { var vm = new ViewModel { @@ -150,7 +157,7 @@ private ViewModel CreateConsentViewModel( }; vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources - .Select(x => CreateScopeViewModel(x, model?.ScopesConsented == null || model.ScopesConsented?.Contains(x.Name) == true)) + .Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name))) .ToArray(); var resourceIndicators = request.Parameters.GetValues(OidcConstants.AuthorizeRequest.Resource) ?? Enumerable.Empty(); @@ -162,7 +169,7 @@ private ViewModel CreateConsentViewModel( var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); if (apiScope != null) { - var scopeVm = CreateScopeViewModel(parsedScope, apiScope, model == null || model.ScopesConsented?.Contains(parsedScope.RawValue) == true); + var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue)); scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName)) .Select(x => new ResourceViewModel { @@ -174,14 +181,14 @@ private ViewModel CreateConsentViewModel( } if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) { - apiScopes.Add(GetOfflineAccessScope(model == null || model.ScopesConsented?.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess) == true)); + apiScopes.Add(CreateOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess))); } vm.ApiScopes = apiScopes; return vm; } - private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) { return new ScopeViewModel { @@ -195,9 +202,9 @@ private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool chec }; } - public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) { - var displayName = apiScope.DisplayName ?? apiScope.Name; + var displayName = apiScope.DisplayName ?? apiScope.Name; if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter)) { displayName += ":" + parsedScopeValue.ParsedParameter; @@ -215,7 +222,7 @@ public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, Ap }; } - private ScopeViewModel GetOfflineAccessScope(bool check) + private static ScopeViewModel CreateOfflineAccessScope(bool check) { return new ScopeViewModel { @@ -226,4 +233,4 @@ private ScopeViewModel GetOfflineAccessScope(bool check) Checked = check }; } -} \ No newline at end of file +} diff --git a/src/Pages/Consent/InputModel.cs b/src/Pages/Consent/InputModel.cs index 621bc21..c79062e 100644 --- a/src/Pages/Consent/InputModel.cs +++ b/src/Pages/Consent/InputModel.cs @@ -1,16 +1,13 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using System.Collections.Generic; - namespace IdentityServerHost.Pages.Consent; public class InputModel { - public string Button { get; set; } - public IEnumerable ScopesConsented { get; set; } + public string? Button { get; set; } + public IEnumerable ScopesConsented { get; set; } = new List(); public bool RememberConsent { get; set; } = true; - public string ReturnUrl { get; set; } - public string Description { get; set; } + public string? ReturnUrl { get; set; } + public string? Description { get; set; } } \ No newline at end of file diff --git a/src/Pages/Consent/ViewModel.cs b/src/Pages/Consent/ViewModel.cs index 69c0506..0a44dc8 100644 --- a/src/Pages/Consent/ViewModel.cs +++ b/src/Pages/Consent/ViewModel.cs @@ -1,35 +1,33 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. -using System.Collections.Generic; - namespace IdentityServerHost.Pages.Consent; public class ViewModel { - public string ClientName { get; set; } - public string ClientUrl { get; set; } - public string ClientLogoUrl { 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; } = Enumerable.Empty(); + public IEnumerable ApiScopes { get; set; } = Enumerable.Empty(); } public class ScopeViewModel { - public string Name { get; set; } - public string Value { get; set; } - public string DisplayName { get; set; } - public string Description { 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; } + public IEnumerable Resources { get; set; } = Enumerable.Empty(); } public class ResourceViewModel { - public string Name { get; set; } - public string DisplayName { get; set; } + public string? Name { get; set; } + public string? DisplayName { get; set; } } \ No newline at end of file diff --git a/src/Pages/Device/DeviceOptions.cs b/src/Pages/Device/DeviceOptions.cs index 421ea7a..05133cc 100644 --- a/src/Pages/Device/DeviceOptions.cs +++ b/src/Pages/Device/DeviceOptions.cs @@ -1,14 +1,13 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - namespace IdentityServerHost.Pages.Device; -public class DeviceOptions +public static class DeviceOptions { - 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 bool EnableOfflineAccess = true; + public static readonly string OfflineAccessDisplayName = "Offline Access"; + public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline"; public static readonly string InvalidUserCode = "Invalid user code"; public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission"; diff --git a/src/Pages/Device/Index.cshtml.cs b/src/Pages/Device/Index.cshtml.cs index aafdf11..29cf3af 100644 --- a/src/Pages/Device/Index.cshtml.cs +++ b/src/Pages/Device/Index.cshtml.cs @@ -1,7 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Events; using Duende.IdentityServer.Extensions; @@ -12,7 +11,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace IdentityServerHost.Pages.Device; @@ -38,26 +36,21 @@ public Index( _logger = logger; } - public ViewModel View { get; set; } + public ViewModel View { get; set; } = default!; [BindProperty] - public InputModel Input { get; set; } + public InputModel Input { get; set; } = default!; - public async Task OnGet(string userCode) + public async Task OnGet(string? userCode) { if (String.IsNullOrWhiteSpace(userCode)) { - View = new ViewModel(); - Input = new InputModel(); return Page(); } - View = await BuildViewModelAsync(userCode); - if (View == null) + if (!await SetViewModelAsync(userCode)) { ModelState.AddModelError("", DeviceOptions.InvalidUserCode); - View = new ViewModel(); - Input = new InputModel(); return Page(); } @@ -70,10 +63,10 @@ public async Task OnGet(string userCode) public async Task OnPost() { - var request = await _interaction.GetAuthorizationContextAsync(Input.UserCode); - if (request == null) return RedirectToPage("/Error/Index"); + var request = await _interaction.GetAuthorizationContextAsync(Input.UserCode ?? throw new ArgumentNullException(nameof(Input.UserCode))); + if (request == null) return RedirectToPage("/Home/Error/Index"); - ConsentResponse grantedConsent = null; + ConsentResponse? grantedConsent = null; // user clicked 'no' - send back the standard 'access_denied' response if (Input.Button == "no") @@ -85,12 +78,13 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") { // if the user consented to some scope, build the response model - if (Input.ScopesConsented != null && Input.ScopesConsented.Any()) + if (Input.ScopesConsented.Any()) { var scopes = Input.ScopesConsented; if (ConsentOptions.EnableOfflineAccess == false) @@ -107,6 +101,9 @@ public async Task OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); } else { @@ -128,23 +125,30 @@ public async Task OnPost() } // we need to redisplay the consent UI - View = await BuildViewModelAsync(Input.UserCode, Input); + if (!await SetViewModelAsync(Input.UserCode)) + { + return RedirectToPage("/Home/Error/Index"); + } return Page(); } - private async Task BuildViewModelAsync(string userCode, InputModel model = null) + private async Task SetViewModelAsync(string userCode) { var request = await _interaction.GetAuthorizationContextAsync(userCode); if (request != null) { - return CreateConsentViewModel(model, request); + View = CreateConsentViewModel(request); + return true; + } + else + { + View = new ViewModel(); + return false; } - - return null; } - private ViewModel CreateConsentViewModel(InputModel model, DeviceFlowAuthorizationRequest request) + private ViewModel CreateConsentViewModel(DeviceFlowAuthorizationRequest request) { var vm = new ViewModel { @@ -154,7 +158,7 @@ private ViewModel CreateConsentViewModel(InputModel model, DeviceFlowAuthorizati AllowRememberConsent = request.Client.AllowRememberConsent }; - vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, model == null || model.ScopesConsented?.Contains(x.Name) == true)).ToArray(); + vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name))).ToArray(); var apiScopes = new List(); foreach (var parsedScope in request.ValidatedResources.ParsedScopes) @@ -162,20 +166,20 @@ private ViewModel CreateConsentViewModel(InputModel model, DeviceFlowAuthorizati var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); if (apiScope != null) { - var scopeVm = CreateScopeViewModel(parsedScope, apiScope, model == null || model.ScopesConsented?.Contains(parsedScope.RawValue) == true); + var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue)); apiScopes.Add(scopeVm); } } if (DeviceOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) { - apiScopes.Add(GetOfflineAccessScope(model == null || model.ScopesConsented?.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess) == true)); + apiScopes.Add(GetOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess))); } vm.ApiScopes = apiScopes; return vm; } - private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) { return new ScopeViewModel { @@ -188,7 +192,7 @@ private ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool chec }; } - public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) { return new ScopeViewModel { @@ -202,7 +206,7 @@ public ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, Ap }; } - private ScopeViewModel GetOfflineAccessScope(bool check) + private static ScopeViewModel GetOfflineAccessScope(bool check) { return new ScopeViewModel { @@ -213,4 +217,4 @@ private ScopeViewModel GetOfflineAccessScope(bool check) Checked = check }; } -} \ No newline at end of file +} diff --git a/src/Pages/Device/InputModel.cs b/src/Pages/Device/InputModel.cs index 07bf8e5..e3c18f6 100644 --- a/src/Pages/Device/InputModel.cs +++ b/src/Pages/Device/InputModel.cs @@ -1,13 +1,14 @@ -using System.Collections.Generic; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. namespace IdentityServerHost.Pages.Device; public class InputModel { - public string Button { get; set; } - public IEnumerable ScopesConsented { get; set; } + public string? Button { get; set; } + public IEnumerable ScopesConsented { get; set; } = new List(); public bool RememberConsent { get; set; } = true; - public string ReturnUrl { get; set; } - public string Description { get; set; } - public string UserCode { get; set; } -} \ No newline at end of file + public string? ReturnUrl { get; set; } + public string? Description { get; set; } + public string? UserCode { get; set; } +} diff --git a/src/Pages/Device/Success.cshtml.cs b/src/Pages/Device/Success.cshtml.cs index 184f650..7494548 100644 --- a/src/Pages/Device/Success.cshtml.cs +++ b/src/Pages/Device/Success.cshtml.cs @@ -1,3 +1,6 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -10,4 +13,4 @@ public class SuccessModel : PageModel public void OnGet() { } -} \ No newline at end of file +} diff --git a/src/Pages/Device/ViewModel.cs b/src/Pages/Device/ViewModel.cs index 403ab05..f277c2c 100644 --- a/src/Pages/Device/ViewModel.cs +++ b/src/Pages/Device/ViewModel.cs @@ -1,24 +1,25 @@ -using System.Collections.Generic; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. namespace IdentityServerHost.Pages.Device; public class ViewModel { - public string ClientName { get; set; } - public string ClientUrl { get; set; } - public string ClientLogoUrl { 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; } = Enumerable.Empty(); + public IEnumerable ApiScopes { get; set; } = Enumerable.Empty(); } public class ScopeViewModel { - public string Value { get; set; } - public string DisplayName { get; set; } - public string Description { 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; } -} \ No newline at end of file +} diff --git a/src/Pages/Diagnostics/Index.cshtml b/src/Pages/Diagnostics/Index.cshtml index c93825d..76397f9 100644 --- a/src/Pages/Diagnostics/Index.cshtml +++ b/src/Pages/Diagnostics/Index.cshtml @@ -13,13 +13,16 @@

Claims

-
- @foreach (var claim in Model.View.AuthenticateResult.Principal.Claims) - { -
@claim.Type
-
@claim.Value
- } -
+ @if(Model.View.AuthenticateResult.Principal != null) + { +
+ @foreach (var claim in Model.View.AuthenticateResult.Principal.Claims) + { +
@claim.Type
+
@claim.Value
+ } +
+ }
@@ -31,10 +34,13 @@
- @foreach (var prop in Model.View.AuthenticateResult.Properties.Items) + @if (Model.View.AuthenticateResult.Properties != null) { -
@prop.Key
-
@prop.Value
+ @foreach (var prop in Model.View.AuthenticateResult.Properties.Items) + { +
@prop.Key
+
@prop.Value
+ } } @if (Model.View.Clients.Any()) { diff --git a/src/Pages/Diagnostics/Index.cshtml.cs b/src/Pages/Diagnostics/Index.cshtml.cs index 4625ae5..3b1d10b 100644 --- a/src/Pages/Diagnostics/Index.cshtml.cs +++ b/src/Pages/Diagnostics/Index.cshtml.cs @@ -1,8 +1,9 @@ -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using System.Linq; using Microsoft.AspNetCore.Authorization; namespace IdentityServerHost.Pages.Diagnostics; @@ -11,10 +12,21 @@ namespace IdentityServerHost.Pages.Diagnostics; [Authorize] public class Index : PageModel { - public ViewModel View { get; set; } - + public ViewModel View { get; set; } = default!; + public async Task OnGet() { + var localAddresses = new List { "127.0.0.1", "::1" }; + if(HttpContext.Connection.LocalIpAddress != null) + { + localAddresses.Add(HttpContext.Connection.LocalIpAddress.ToString()); + } + + if (!localAddresses.Contains(HttpContext.Connection.RemoteIpAddress?.ToString())) + { + return NotFound(); + } + View = new ViewModel(await HttpContext.AuthenticateAsync()); return Page(); diff --git a/src/Pages/Diagnostics/ViewModel.cs b/src/Pages/Diagnostics/ViewModel.cs index 6f52022..174cde0 100644 --- a/src/Pages/Diagnostics/ViewModel.cs +++ b/src/Pages/Diagnostics/ViewModel.cs @@ -1,10 +1,8 @@ // 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; @@ -16,16 +14,19 @@ public ViewModel(AuthenticateResult result) { AuthenticateResult = result; - if (result.Properties.Items.ContainsKey("client_list")) + if (result?.Properties?.Items.TryGetValue("client_list", out var encoded) == true) { - var encoded = result.Properties.Items["client_list"]; - var bytes = Base64Url.Decode(encoded); - var value = Encoding.UTF8.GetString(bytes); - - Clients = JsonSerializer.Deserialize(value); + if (encoded != null) + { + var bytes = Base64Url.Decode(encoded); + var value = Encoding.UTF8.GetString(bytes); + Clients = JsonSerializer.Deserialize(value) ?? Enumerable.Empty(); + return; + } } + Clients = Enumerable.Empty(); } public AuthenticateResult AuthenticateResult { get; } - public IEnumerable Clients { get; } = new List(); + public IEnumerable Clients { get; } } \ No newline at end of file diff --git a/src/Pages/Extensions.cs b/src/Pages/Extensions.cs index eea7335..96aa252 100644 --- a/src/Pages/Extensions.cs +++ b/src/Pages/Extensions.cs @@ -1,15 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - -using System; -using System.Threading.Tasks; using Duende.IdentityServer.Models; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.DependencyInjection; namespace IdentityServerHost.Pages; @@ -18,7 +13,7 @@ public static class Extensions /// /// Determines if the authentication scheme support signout. /// - public static async Task GetSchemeSupportsSignOutAsync(this HttpContext context, string scheme) + internal static async Task GetSchemeSupportsSignOutAsync(this HttpContext context, string scheme) { var provider = context.RequestServices.GetRequiredService(); var handler = await provider.GetHandlerAsync(context, scheme); @@ -28,7 +23,7 @@ public static async Task GetSchemeSupportsSignOutAsync(this HttpContext co /// /// Checks if the redirect URI is for a native client. /// - public static bool IsNativeClient(this AuthorizationRequest context) + internal static bool IsNativeClient(this AuthorizationRequest context) { return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal) && !context.RedirectUri.StartsWith("http", StringComparison.Ordinal); @@ -37,7 +32,7 @@ public static bool IsNativeClient(this AuthorizationRequest context) /// /// Renders a loading page that is used to redirect back to the redirectUri. /// - public static IActionResult LoadingPage(this PageModel page, string redirectUri) + internal static IActionResult LoadingPage(this PageModel page, string? redirectUri) { page.HttpContext.Response.StatusCode = 200; page.HttpContext.Response.Headers["Location"] = ""; diff --git a/src/Pages/ExternalLogin/Callback.cshtml.cs b/src/Pages/ExternalLogin/Callback.cshtml.cs index 024e7ba..fb70ec8 100644 --- a/src/Pages/ExternalLogin/Callback.cshtml.cs +++ b/src/Pages/ExternalLogin/Callback.cshtml.cs @@ -1,8 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using System.Security.Claims; -using System.Threading.Tasks; using Duende.IdentityServer; using Duende.IdentityServer.Events; using Duende.IdentityServer.Services; @@ -10,10 +9,8 @@ using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Logging; namespace IdentityServerHost.Pages.ExternalLogin; @@ -30,10 +27,10 @@ public Callback( IIdentityServerInteractionService interaction, IEventService events, ILogger logger, - TestUserStore users = null) + TestUserStore? users = null) { // this is where you would plug in your own custom identity management library (e.g. ASP.NET Identity) - _users = users ?? throw new Exception("Please call 'AddTestUsers(TestUsers.Users)' on the IIdentityServerBuilder in Startup or remove the TestUserStore from the AccountController."); + _users = users ?? throw new InvalidOperationException("Please call 'AddTestUsers(TestUsers.Users)' on the IIdentityServerBuilder in Startup or remove the TestUserStore from the AccountController."); _interaction = interaction; _logger = logger; @@ -44,17 +41,18 @@ public async Task OnGet() { // read external identity from the temporary cookie var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); - if (result?.Succeeded != true) + if (result.Succeeded != true) { - throw new Exception("External authentication error"); + throw new InvalidOperationException($"External authentication error: { result.Failure }"); } - var externalUser = result.Principal; - + var externalUser = result.Principal ?? + throw new InvalidOperationException("External authentication produced a null Principal"); + if (_logger.IsEnabled(LogLevel.Debug)) { var externalClaims = externalUser.Claims.Select(c => $"{c.Type}: {c.Value}"); - _logger.LogDebug("External claims: {@claims}", externalClaims); + _logger.ExternalClaims(externalClaims); } // lookup our user and external provider info @@ -63,9 +61,9 @@ public async Task OnGet() // 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"); + throw new InvalidOperationException("Unknown userid"); - var provider = result.Properties.Items["scheme"]; + var provider = result.Properties.Items["scheme"] ?? throw new InvalidOperationException("Null scheme in authentiation properties"); var providerUserId = userIdClaim.Value; // find external user @@ -108,6 +106,7 @@ public async Task OnGet() // 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.SubjectId, user.Username, true, context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!); if (context != null) { @@ -124,8 +123,13 @@ public async Task OnGet() // 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 CaptureExternalLoginContext(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps) + private static void CaptureExternalLoginContext(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps) { + ArgumentNullException.ThrowIfNull(externalResult.Principal, nameof(externalResult.Principal)); + + // capture the idp used to login, so the session knows where the user came from + localClaims.Add(new Claim(JwtClaimTypes.IdentityProvider, externalResult.Properties?.Items["scheme"] ?? "unknown identity provider")); + // 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); @@ -135,10 +139,10 @@ private void CaptureExternalLoginContext(AuthenticateResult externalResult, List } // if the external provider issued an id_token, we'll keep it for signout - var idToken = externalResult.Properties.GetTokenValue("id_token"); + 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/src/Pages/ExternalLogin/Challenge.cshtml.cs b/src/Pages/ExternalLogin/Challenge.cshtml.cs index 38acb1d..53188a5 100644 --- a/src/Pages/ExternalLogin/Challenge.cshtml.cs +++ b/src/Pages/ExternalLogin/Challenge.cshtml.cs @@ -1,4 +1,6 @@ -using System; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -18,7 +20,7 @@ public Challenge(IIdentityServerInteractionService interactionService) _interactionService = interactionService; } - public IActionResult OnGet(string scheme, string returnUrl) + public IActionResult OnGet(string scheme, string? returnUrl) { if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/"; @@ -26,7 +28,7 @@ public IActionResult OnGet(string scheme, string returnUrl) if (Url.IsLocalUrl(returnUrl) == false && _interactionService.IsValidReturnUrl(returnUrl) == false) { // user might have clicked on a malicious link - should be logged - throw new Exception("invalid return URL"); + throw new ArgumentException("invalid return URL"); } // start challenge and roundtrip the return URL and scheme @@ -43,4 +45,4 @@ public IActionResult OnGet(string scheme, string returnUrl) return Challenge(props, scheme); } -} \ No newline at end of file +} diff --git a/src/Pages/Grants/Index.cshtml b/src/Pages/Grants/Index.cshtml index 9d3ffae..83e373a 100644 --- a/src/Pages/Grants/Index.cshtml +++ b/src/Pages/Grants/Index.cshtml @@ -9,7 +9,7 @@

Below is the list of applications you have given permission to and the resources they have access to.

- @if (Model.View.Grants.Any() == false) + @if (!Model.View.Grants.Any()) {
@@ -21,7 +21,7 @@ } else { - foreach (var grant in Model.View.Grants) + foreach (var grant in Model.View.Grants) {
diff --git a/src/Pages/Grants/Index.cshtml.cs b/src/Pages/Grants/Index.cshtml.cs index d14931e..051f00d 100644 --- a/src/Pages/Grants/Index.cshtml.cs +++ b/src/Pages/Grants/Index.cshtml.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer.Events; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Services; @@ -32,7 +31,7 @@ public Index(IIdentityServerInteractionService interaction, _events = events; } - public ViewModel View { get; set; } + public ViewModel View { get; set; } = default!; public async Task OnGet() { @@ -53,7 +52,7 @@ public async Task OnGet() ClientLogoUrl = client.LogoUri, ClientUrl = client.ClientUri, Description = grant.Description, - Created = grant.CreationTime, + 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() @@ -70,14 +69,14 @@ public async Task OnGet() } [BindProperty] - [Required] - public string ClientId { get; set; } + public string? ClientId { get; set; } public async Task OnPost() { await _interaction.RevokeUserConsentAsync(ClientId); await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId)); + Telemetry.Metrics.GrantsRevoked(ClientId); return RedirectToPage("/Grants/Index"); } -} \ No newline at end of file +} diff --git a/src/Pages/Grants/ViewModel.cs b/src/Pages/Grants/ViewModel.cs index 7f915d8..5006d41 100644 --- a/src/Pages/Grants/ViewModel.cs +++ b/src/Pages/Grants/ViewModel.cs @@ -1,22 +1,22 @@ -using System; -using System.Collections.Generic; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. namespace IdentityServerHost.Pages.Grants; public class ViewModel { - public IEnumerable Grants { get; set; } + public IEnumerable Grants { get; set; } = Enumerable.Empty(); } 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 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 IEnumerable IdentityGrantNames { get; set; } = Enumerable.Empty(); + public IEnumerable ApiGrantNames { get; set; } = Enumerable.Empty(); +} diff --git a/src/Pages/Home/Error/Index.cshtml.cs b/src/Pages/Home/Error/Index.cshtml.cs index 3b29260..be873ef 100644 --- a/src/Pages/Home/Error/Index.cshtml.cs +++ b/src/Pages/Home/Error/Index.cshtml.cs @@ -1,9 +1,9 @@ -using System.Threading.Tasks; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.Extensions.Hosting; namespace IdentityServerHost.Pages.Error; @@ -14,7 +14,7 @@ public class Index : PageModel private readonly IIdentityServerInteractionService _interaction; private readonly IWebHostEnvironment _environment; - public ViewModel View { get; set; } + public ViewModel View { get; set; } = new(); public Index(IIdentityServerInteractionService interaction, IWebHostEnvironment environment) { @@ -22,10 +22,8 @@ public Index(IIdentityServerInteractionService interaction, IWebHostEnvironment _environment = environment; } - public async Task OnGet(string errorId) + public async Task OnGet(string? errorId) { - View = new ViewModel(); - // retrieve error details from identityserver var message = await _interaction.GetErrorContextAsync(errorId); if (message != null) @@ -39,4 +37,4 @@ public async Task OnGet(string errorId) } } } -} \ No newline at end of file +} diff --git a/src/Pages/Home/Error/ViewModel.cs b/src/Pages/Home/Error/ViewModel.cs index 6a842b0..5fe9255 100644 --- a/src/Pages/Home/Error/ViewModel.cs +++ b/src/Pages/Home/Error/ViewModel.cs @@ -16,5 +16,5 @@ public ViewModel(string error) Error = new ErrorMessage { Error = error }; } - public ErrorMessage Error { get; set; } + public ErrorMessage? Error { get; set; } } \ No newline at end of file diff --git a/src/Pages/IdentityServerSuppressions.cs b/src/Pages/IdentityServerSuppressions.cs new file mode 100644 index 0000000..ec56a0c --- /dev/null +++ b/src/Pages/IdentityServerSuppressions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +// global/shared +[assembly: SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Consistent with the IdentityServer APIs")] +[assembly: SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "Consistent with the IdentityServer APIs")] +[assembly: SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "No need for ConfigureAwait in ASP.NET Core application code, as there is no SynchronizationContext.")] + +// page specific +[assembly: SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "TestUsers are not designed to be extended", Scope = "member", Target = "~P:IdentityServerHost.TestUsers.Users")] +[assembly: SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "ExternalProvider is nested by design", Scope = "type", Target = "~T:IdentityServerHost.Pages.Login.ViewModel.ExternalProvider")] +[assembly: SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "This namespace is just for organization, and won't be referenced elsewhere", Scope = "namespace", Target = "~N:IdentityServerHost.Pages.Error")] +[assembly: SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Namespaces of pages are not likely to be used elsewhere, so there is little chance of confusion", Scope = "type", Target = "~T:IdentityServerHost.Pages.Ciba.Consent")] +[assembly: SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Namespaces of pages are not likely to be used elsewhere, so there is little chance of confusion", Scope = "type", Target = "~T:IdentityServerHost.Pages.Extensions")] +[assembly: SuppressMessage("Performance", "CA1805:Do not initialize unnecessarily", Justification = "This is for clarity and consistency with the surrounding code", Scope = "member", Target = "~F:IdentityServerHost.Pages.Logout.LogoutOptions.AutomaticRedirectAfterSignOut")] diff --git a/src/Pages/Index.cshtml b/src/Pages/Index.cshtml index 6e3c3ff..8608a13 100644 --- a/src/Pages/Index.cshtml +++ b/src/Pages/Index.cshtml @@ -20,6 +20,9 @@
  • Click here to manage your stored grants.
  • +
  • + Click here to view the server side sessions. +
  • Click here to view your pending CIBA login requests.
  • diff --git a/src/Pages/Index.cshtml.cs b/src/Pages/Index.cshtml.cs index 70ac3f2..4a6fabd 100644 --- a/src/Pages/Index.cshtml.cs +++ b/src/Pages/Index.cshtml.cs @@ -1,4 +1,7 @@ -using System.Linq; +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer; using System.Reflection; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -8,10 +11,17 @@ namespace IdentityServerHost.Pages.Home; [AllowAnonymous] public class Index : PageModel { - public string Version; - - public void OnGet() + public Index(IdentityServerLicense? license = null) + { + License = license; + } + + public string Version { - Version = typeof(Duende.IdentityServer.Hosting.IdentityServerMiddleware).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+').First(); + get => typeof(Duende.IdentityServer.Hosting.IdentityServerMiddleware).Assembly + .GetCustomAttribute() + ?.InformationalVersion.Split('+').First() + ?? "unavailable"; } -} \ No newline at end of file + public IdentityServerLicense? License { get; } +} diff --git a/src/Pages/Log.cs b/src/Pages/Log.cs new file mode 100644 index 0000000..5151fce --- /dev/null +++ b/src/Pages/Log.cs @@ -0,0 +1,87 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentityServerHost.Pages; + +internal static class Log +{ + private static readonly Action _invalidId = LoggerMessage.Define( + LogLevel.Error, + EventIds.InvalidId, + "Invalid id {Id}"); + + public static void InvalidId(this ILogger logger, string? id) + { + _invalidId(logger, id, null); + } + + private static readonly Action _invalidBackchannelLoginId = LoggerMessage.Define( + LogLevel.Warning, + EventIds.InvalidBackchannelLoginId, + "Invalid backchannel login id {Id}"); + + public static void InvalidBackchannelLoginId(this ILogger logger, string? id) + { + _invalidBackchannelLoginId(logger, id, null); + } + + private static Action, Exception?> _externalClaims = LoggerMessage.Define>( + LogLevel.Debug, + EventIds.ExternalClaims, + "External claims: {Claims}"); + + public static void ExternalClaims(this ILogger logger, IEnumerable claims) + { + _externalClaims(logger, claims, null); + } + + private static Action _noMatchingBackchannelLoginRequest = LoggerMessage.Define( + LogLevel.Error, + EventIds.NoMatchingBackchannelLoginRequest, + "No backchannel login request matching id: {Id}"); + + public static void NoMatchingBackchannelLoginRequest(this ILogger logger, string id) + { + _noMatchingBackchannelLoginRequest(logger, id, null); + } + + private static Action _noConsentMatchingRequest = LoggerMessage.Define( + LogLevel.Error, + EventIds.NoConsentMatchingRequest, + "No consent request matching request: {ReturnUrl}"); + + public static void NoConsentMatchingRequest(this ILogger logger, string returnUrl) + { + _noConsentMatchingRequest(logger, returnUrl, null); + } + + +} + +internal static class EventIds +{ + private const int UIEventsStart = 10000; + + ////////////////////////////// + // Consent + ////////////////////////////// + private const int ConsentEventsStart = UIEventsStart + 1000; + public const int InvalidId = ConsentEventsStart + 0; + public const int NoConsentMatchingRequest = ConsentEventsStart + 1; + + ////////////////////////////// + // External Login + ////////////////////////////// + private const int ExternalLoginEventsStart = UIEventsStart + 2000; + public const int ExternalClaims = ExternalLoginEventsStart + 0; + + ////////////////////////////// + // CIBA + ////////////////////////////// + private const int CibaEventsStart = UIEventsStart + 3000; + public const int InvalidBackchannelLoginId = CibaEventsStart + 0; + public const int NoMatchingBackchannelLoginRequest = CibaEventsStart + 1; + + + +} diff --git a/src/Pages/Redirect/Index.cshtml.cs b/src/Pages/Redirect/Index.cshtml.cs index 5fe9dee..711c284 100644 --- a/src/Pages/Redirect/Index.cshtml.cs +++ b/src/Pages/Redirect/Index.cshtml.cs @@ -1,3 +1,6 @@ +// 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.AspNetCore.Mvc.RazorPages; @@ -7,16 +10,16 @@ namespace IdentityServerHost.Pages.Redirect; [AllowAnonymous] public class IndexModel : PageModel { - public string RedirectUri { get; set; } + public string? RedirectUri { get; set; } - public IActionResult OnGet(string redirectUri) + public IActionResult OnGet(string? redirectUri) { if (!Url.IsLocalUrl(redirectUri)) { - return RedirectToPage("/Error/Index"); + return RedirectToPage("/Home/Error/Index"); } RedirectUri = redirectUri; return Page(); } -} \ No newline at end of file +} diff --git a/src/Pages/SecurityHeadersAttribute.cs b/src/Pages/SecurityHeadersAttribute.cs index 0908172..aa90ad4 100644 --- a/src/Pages/SecurityHeadersAttribute.cs +++ b/src/Pages/SecurityHeadersAttribute.cs @@ -1,29 +1,30 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.RazorPages; namespace IdentityServerHost.Pages; -public class SecurityHeadersAttribute : ActionFilterAttribute +public sealed class SecurityHeadersAttribute : ActionFilterAttribute { public override void OnResultExecuting(ResultExecutingContext context) { + ArgumentNullException.ThrowIfNull(context, nameof(context)); + var result = context.Result; if (result is PageResult) { // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options if (!context.HttpContext.Response.Headers.ContainsKey("X-Content-Type-Options")) { - context.HttpContext.Response.Headers.Add("X-Content-Type-Options", "nosniff"); + context.HttpContext.Response.Headers.Append("X-Content-Type-Options", "nosniff"); } // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options if (!context.HttpContext.Response.Headers.ContainsKey("X-Frame-Options")) { - context.HttpContext.Response.Headers.Add("X-Frame-Options", "SAMEORIGIN"); + context.HttpContext.Response.Headers.Append("X-Frame-Options", "DENY"); } // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy @@ -36,19 +37,19 @@ public override void OnResultExecuting(ResultExecutingContext context) // once for standards compliant browsers if (!context.HttpContext.Response.Headers.ContainsKey("Content-Security-Policy")) { - context.HttpContext.Response.Headers.Add("Content-Security-Policy", csp); + context.HttpContext.Response.Headers.Append("Content-Security-Policy", csp); } // and once again for IE if (!context.HttpContext.Response.Headers.ContainsKey("X-Content-Security-Policy")) { - context.HttpContext.Response.Headers.Add("X-Content-Security-Policy", csp); + context.HttpContext.Response.Headers.Append("X-Content-Security-Policy", csp); } // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy var referrer_policy = "no-referrer"; if (!context.HttpContext.Response.Headers.ContainsKey("Referrer-Policy")) { - context.HttpContext.Response.Headers.Add("Referrer-Policy", referrer_policy); + context.HttpContext.Response.Headers.Append("Referrer-Policy", referrer_policy); } } } diff --git a/src/Pages/ServerSideSessions/Index.cshtml b/src/Pages/ServerSideSessions/Index.cshtml new file mode 100644 index 0000000..2852f93 --- /dev/null +++ b/src/Pages/ServerSideSessions/Index.cshtml @@ -0,0 +1,147 @@ +@page +@model IdentityServerHost.Pages.ServerSideSessions.IndexModel + +
    +
    +
    +
    +
    +

    User Sessions

    +
    + +
    + + @if (Model.UserSessions != null) + { +
    +
    + @if (Model.UserSessions.HasPrevResults) + { + Prev + } +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    + @if (Model.UserSessions.HasNextResults) + { + Next + } +
    +
    + + @if (Model.UserSessions.TotalCount.HasValue) + { +
    + @if (Model.UserSessions.CurrentPage.HasValue && Model.UserSessions.TotalPages.HasValue) + { + + Total Results: @Model.UserSessions.TotalCount, + Page @Model.UserSessions.CurrentPage of @Model.UserSessions.TotalPages + + } + else + { + + Total Results: @Model.UserSessions.TotalCount + + } +
    + } + +
    + + @if (Model.UserSessions.Results.Any()) + { +
    +
    + + + + + + + + + + + + @foreach (var session in Model.UserSessions.Results) + { + + + + + + + + + + } + +
    Subject IdSession IdDisplay NameCreatedExpires
    @session.SubjectId@session.SessionId@session.DisplayName@session.Created@session.Expires +
    + + +
    +
    + Clients: + @if (session.ClientIds?.Any() == true) + { + @(session.ClientIds.Aggregate((x, y) => $"{x}, {y}")) + } + else + { + @("None") + } +
    +
    + } + else + { +
    No User Sessions
    + } + } + else + { +
    +
    + You do not have server-side sessions enabled. + To do so, use AddServerSideSessions on your IdentityServer configuration. + See the documentation for more information. +
    +
    + } + + + + + \ No newline at end of file diff --git a/src/Pages/ServerSideSessions/Index.cshtml.cs b/src/Pages/ServerSideSessions/Index.cshtml.cs new file mode 100644 index 0000000..fbc7f78 --- /dev/null +++ b/src/Pages/ServerSideSessions/Index.cshtml.cs @@ -0,0 +1,67 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentityServerHost.Pages.ServerSideSessions +{ + public class IndexModel : PageModel + { + private readonly ISessionManagementService? _sessionManagementService; + + public IndexModel(ISessionManagementService? sessionManagementService = null) + { + _sessionManagementService = sessionManagementService; + } + + public QueryResult? UserSessions { get; set; } + + [BindProperty(SupportsGet = true)] + public string? DisplayNameFilter { get; set; } + + [BindProperty(SupportsGet = true)] + public string? SessionIdFilter { get; set; } + + [BindProperty(SupportsGet = true)] + public string? SubjectIdFilter { get; set; } + + [BindProperty(SupportsGet = true)] + public string? Token { get; set; } + + [BindProperty(SupportsGet = true)] + public string? Prev { get; set; } + + public async Task OnGet() + { + if (_sessionManagementService != null) + { + UserSessions = await _sessionManagementService.QuerySessionsAsync(new SessionQuery + { + ResultsToken = Token, + RequestPriorResults = Prev == "true", + DisplayName = DisplayNameFilter, + SessionId = SessionIdFilter, + SubjectId = SubjectIdFilter + }); + } + } + + [BindProperty] + public string? SessionId { get; set; } + + public async Task OnPost() + { + ArgumentNullException.ThrowIfNull(_sessionManagementService); + + await _sessionManagementService.RemoveSessionsAsync(new RemoveSessionsContext { + SessionId = SessionId, + }); + return RedirectToPage("/ServerSideSessions/Index", new { Token, DisplayNameFilter, SessionIdFilter, SubjectIdFilter, Prev }); + } + } +} + diff --git a/src/Pages/Shared/_Nav.cshtml b/src/Pages/Shared/_Nav.cshtml index 405675b..e678cf1 100644 --- a/src/Pages/Shared/_Nav.cshtml +++ b/src/Pages/Shared/_Nav.cshtml @@ -1,6 +1,7 @@ @using Duende.IdentityServer.Extensions @{ - string name = null; + #nullable enable + string? name = null; if (!true.Equals(ViewData["signed-out"])) { name = Context.User?.GetDisplayName(); diff --git a/src/Pages/Telemetry.cs b/src/Pages/Telemetry.cs new file mode 100644 index 0000000..3bafe1b --- /dev/null +++ b/src/Pages/Telemetry.cs @@ -0,0 +1,142 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Diagnostics.Metrics; + +namespace IdentityServerHost.Pages; + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1724 // Type names should not match namespaces + +/// +/// Telemetry helpers for the UI +/// +public static class Telemetry +{ + private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString(); + + /// + /// Service name for telemetry. + /// + public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!; + + /// + /// Metrics configuration + /// + public static class Metrics + { +#pragma warning disable 1591 + + /// + /// Name of Counters + /// + public static class Counters + { + public const string Consent = "tokenservice.consent"; + public const string GrantsRevoked = "tokenservice.grants_revoked"; + public const string UserLogin = "tokenservice.user_login"; + public const string UserLogout = "tokenservice.user_logout"; + } + + /// + /// Name of tags + /// + public static class Tags + { + public const string Client = "client"; + public const string Error = "error"; + public const string Idp = "idp"; + public const string Remember = "remember"; + public const string Scope = "scope"; + public const string Consent = "consent"; + } + + /// + /// Values of tags + /// + public static class TagValues + { + public const string Granted = "granted"; + public const string Denied = "denied"; + } + +#pragma warning restore 1591 + + /// + /// Meter for the IdentityServer host project + /// + private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion); + + private static Counter ConsentCounter = Meter.CreateCounter(Counters.Consent); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentGranted(string clientId, IEnumerable scopes, bool remember) + { + ArgumentNullException.ThrowIfNull(scopes); + + foreach (var scope in scopes) + { + ConsentCounter.Add(1, + new(Tags.Client, clientId), + new(Tags.Scope, scope), + new(Tags.Remember, remember), + new(Tags.Consent, TagValues.Granted)); + } + } + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentDenied(string clientId, IEnumerable scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach (var scope in scopes) + { + ConsentCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Consent, TagValues.Denied)); + } + } + + private static Counter GrantsRevokedCounter = Meter.CreateCounter(Counters.GrantsRevoked); + + /// + /// Helper method to increase the counter. + /// + /// Client id to revoke for, or null for all. + public static void GrantsRevoked(string? clientId) + => GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId)); + + private static Counter UserLoginCounter = Meter.CreateCounter(Counters.UserLogin); + + /// + /// Helper method to increase counter. + /// + /// Client Id, if available + public static void UserLogin(string? clientId, string idp) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp)); + + /// + /// Helper method to increase + /// Client Id, if available + /// Error message + public static void UserLoginFailure(string? clientId, string idp, string error) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error)); + + private static Counter UserLogoutCounter = Meter.CreateCounter(Counters.UserLogout); + + /// + /// Helper method to increase the counter. + /// + /// Idp/authentication scheme for external authentication, or "local" for built in. + public static void UserLogout(string? idp) + => UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp)); + } +} diff --git a/src/Pages/TestUsers.cs b/src/Pages/TestUsers.cs index 2db9cc4..bf91fba 100644 --- a/src/Pages/TestUsers.cs +++ b/src/Pages/TestUsers.cs @@ -1,9 +1,7 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - using IdentityModel; -using System.Collections.Generic; using System.Security.Claims; using System.Text.Json; using Duende.IdentityServer; @@ -11,7 +9,7 @@ namespace IdentityServerHost; -public class TestUsers +public static class TestUsers { public static List Users { @@ -21,7 +19,7 @@ public static List Users { street_address = "One Hacker Way", locality = "Heidelberg", - postal_code = 69118, + postal_code = "69118", country = "Germany" };