diff --git a/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs b/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs index 9e07c5b65..249f75b05 100644 --- a/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Account/Login/Index.cshtml.cs @@ -97,6 +97,7 @@ public async Task<IActionResult> 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. @@ -144,7 +145,9 @@ public async Task<IActionResult> OnPost() } } - 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); } diff --git a/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs b/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs index 9f9c4dc1e..aef1de3d8 100644 --- a/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Account/Logout/Index.cshtml.cs @@ -71,12 +71,13 @@ public async Task<IActionResult> 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) { diff --git a/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs b/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs index 4ebf8cd53..f615fdf05 100644 --- a/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs +++ b/hosts/Configuration/Pages/Ciba/Consent.cshtml.cs @@ -69,6 +69,7 @@ public async Task<IActionResult> OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -90,6 +91,9 @@ public async Task<IActionResult> OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, result.ScopesValuesConsented, false)); + Telemetry.Metrics.ConsentGrantedEvent(request.Client.ClientId, result.ScopesValuesConsented, false); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(result.ScopesValuesConsented); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/Consent/Index.cshtml.cs b/hosts/Configuration/Pages/Consent/Index.cshtml.cs index 2abdee20a..357691a39 100644 --- a/hosts/Configuration/Pages/Consent/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Consent/Index.cshtml.cs @@ -66,6 +66,7 @@ public async Task<IActionResult> OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -88,6 +89,9 @@ public async Task<IActionResult> OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGrantedEvent(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/Device/Index.cshtml.cs b/hosts/Configuration/Pages/Device/Index.cshtml.cs index f84bbb159..0d1721e44 100644 --- a/hosts/Configuration/Pages/Device/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Device/Index.cshtml.cs @@ -78,6 +78,7 @@ public async Task<IActionResult> OnPost() // emit event await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); } // user clicked 'yes' - validate the data else if (Input.Button == "yes") @@ -100,6 +101,9 @@ public async Task<IActionResult> OnPost() // emit event await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGrantedEvent(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDeniedEvent(request.Client.ClientId, denied); } else { diff --git a/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs b/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs index 937ced8e9..281131bc2 100644 --- a/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs +++ b/hosts/Configuration/Pages/ExternalLogin/Callback.cshtml.cs @@ -106,6 +106,7 @@ public async Task<IActionResult> 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) { diff --git a/hosts/Configuration/Pages/Grants/Index.cshtml.cs b/hosts/Configuration/Pages/Grants/Index.cshtml.cs index dceca1034..a38545744 100644 --- a/hosts/Configuration/Pages/Grants/Index.cshtml.cs +++ b/hosts/Configuration/Pages/Grants/Index.cshtml.cs @@ -75,6 +75,7 @@ public async Task<IActionResult> OnPost() { await _interaction.RevokeUserConsentAsync(ClientId); await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId)); + Telemetry.Metrics.GrantsRevoked(ClientId); return RedirectToPage("/Grants/Index"); } diff --git a/hosts/Configuration/Pages/Telemetry.cs b/hosts/Configuration/Pages/Telemetry.cs new file mode 100644 index 000000000..15a993c6d --- /dev/null +++ b/hosts/Configuration/Pages/Telemetry.cs @@ -0,0 +1,170 @@ +using Duende.IdentityServer.Events; +using Microsoft.CodeAnalysis.CSharp.Syntax; +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 + +/// <summary> +/// Telemetry helpers for the UI +/// </summary> +public static class Telemetry +{ + private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString(); + + /// <summary> + /// Service name for telemetry. + /// </summary> + public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!; + + /// <summary> + /// Metrics configuration + /// </summary> + public static class Metrics + { + /// <summary> + /// Name of Counters + /// </summary> + public static class Counters + { + /// <summary> + /// consent_granted + /// </summary> + public const string ConsentGranted = "consent_granted"; + + /// <summary> + /// consent_denied + /// </summary> + public const string ConsentDenied = "consent_denied"; + + /// <summary> + /// grants_revoked + /// </summary> + public const string GrantsRevoked = "grants_revoked"; + + /// <summary> + /// user_login + /// </summary> + public const string UserLogin = "user_login"; + + /// <summary> + /// user_login_failure + /// </summary> + public const string UserLoginFailure = "user_login_failure"; + + /// <summary> + /// user_logout + /// </summary> + public const string UserLogout = "user_logout"; + } + + /// <summary> + /// Name of tags + /// </summary> + public static class Tags + { + /// <summary> + /// client + /// </summary> + public const string Client = "client"; + + /// <summary> + /// error + /// </summary> + public const string Error = "error"; + + /// <summary> + /// idp + /// </summary> + public const string Idp = "idp"; + + /// <summary> + /// remember + /// </summary> + public const string Remember = "remember"; + + /// <summary> + /// scope + /// </summary> + public const string Scope = "scope"; + } + + /// <summary> + /// Meter for the IdentityServer host project + /// </summary> + private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion); + + private static Counter<long> ConsentGrantedCounter = Meter.CreateCounter<long>(Counters.ConsentGranted); + + /// <summary> + /// Helper method to increase <see cref="Counters.ConsentGranted"/> counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// </summary> + /// <param name="clientId">Client id</param> + /// <param name="scopes">Scope names. Each element is added on it's own to the counter</param> + public static void ConsentGrantedEvent(string clientId, IEnumerable<string> scopes, bool remember) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach(var scope in scopes) + { + ConsentGrantedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Remember, remember)); + } + } + + private static Counter<long> ConsentDeniedCounter = Meter.CreateCounter<long>(Counters.ConsentDenied); + + /// <summary> + /// Helper method to increase <see cref="Counters.ConsentDenied"/> counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// </summary> + /// <param name="clientId">Client id</param> + /// <param name="scopes">Scope names. Each element is added on it's own to the counter</param> + public static void ConsentDeniedEvent(string clientId, IEnumerable<string> scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach (var scope in scopes) + { + ConsentDeniedCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope)); + } + } + + private static Counter<long> GrantsRevokedCounter = Meter.CreateCounter<long>(Counters.GrantsRevoked); + + /// <summary> + /// Helper method to increase the <see cref="Counters.GrantsRevoked"/> counter. + /// </summary> + /// <param name="clientId">Client id to revoke for, or null for all.</param> + public static void GrantsRevoked(string? clientId) + => GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId)); + + private static Counter<long> UserLoginCounter = Meter.CreateCounter<long>(Counters.UserLogin); + + /// <summary> + /// Helper method to increase <see cref="Counters.UserLogin"/> counter. + /// </summary> + /// <param name="clientId">Client Id, if available</param> + public static void UserLogin(string? clientId, string idp) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp)); + + private static Counter<long> UserLoginFailureCounter = Meter.CreateCounter<long>(Counters.UserLoginFailure); + + /// <summary> + /// Helper method to increase <see cref="Counters.UserLoginFailure" counter. + /// </summary> + /// <param name="clientId">Client Id, if available</param> + /// <param name="error">Error message</param> + public static void UserLoginFailure(string? clientId, string idp, string error) + => UserLoginFailureCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error)); + + private static Counter<long> UserLogoutCounter = Meter.CreateCounter<long>(Counters.UserLogout); + + /// <summary> + /// Helper method to increase the <see cref="Counters.UserLogout"/> counter. + /// </summary> + /// <param name="idp">Idp/authentication scheme for external authentication, or "local" for built in.</param> + public static void UserLogout(string? idp) + => UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp)); + } +}