diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 7511256d4..e13e2d129 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -218,6 +218,7 @@ public static IIdentityServerBuilder AddCoreServices(this IIdentityServerBuilder builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/identity-server/src/IdentityServer/Endpoints/AuthorizeEndpointBase.cs b/identity-server/src/IdentityServer/Endpoints/AuthorizeEndpointBase.cs index c479580a0..8a608980e 100644 --- a/identity-server/src/IdentityServer/Endpoints/AuthorizeEndpointBase.cs +++ b/identity-server/src/IdentityServer/Endpoints/AuthorizeEndpointBase.cs @@ -239,7 +239,12 @@ private Task RaiseResponseEventAsync(AuthorizeResponse response) Telemetry.Metrics.TokenIssued( response.Request.ClientId, response.Request.GrantType, - response.Request.AuthorizeRequestType); + response.Request.AuthorizeRequestType, + response.AccessToken.IsPresent(), + response.AccessToken.IsPresent() ? response.Request.AccessTokenType : null, + false, + ProofType.None, + response.IdentityToken.IsPresent()); return _events.RaiseAsync(new TokenIssuedSuccessEvent(response)); } diff --git a/identity-server/src/IdentityServer/Endpoints/TokenEndpoint.cs b/identity-server/src/IdentityServer/Endpoints/TokenEndpoint.cs index 23a1cedad..875ed27e5 100644 --- a/identity-server/src/IdentityServer/Endpoints/TokenEndpoint.cs +++ b/identity-server/src/IdentityServer/Endpoints/TokenEndpoint.cs @@ -139,7 +139,10 @@ private async Task ProcessTokenRequestAsync(HttpContext context var response = await _responseGenerator.ProcessAsync(requestResult); await _events.RaiseAsync(new TokenIssuedSuccessEvent(response, requestResult)); - Telemetry.Metrics.TokenIssued(clientResult.Client.ClientId, requestResult.ValidatedRequest.GrantType, null); + + Telemetry.Metrics.TokenIssued(clientResult.Client.ClientId, requestResult.ValidatedRequest.GrantType, null, + response.AccessToken.IsPresent(), response.AccessTokenType.IsPresent() ? requestResult.ValidatedRequest.AccessTokenType : null, response.RefreshToken.IsPresent(), + requestResult.ValidatedRequest.ProofType, response.IdentityToken.IsPresent()); LogTokens(response, requestResult); // return result diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/TokenIssueCountDiagnosticEntry.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/TokenIssueCountDiagnosticEntry.cs new file mode 100644 index 000000000..1f977712a --- /dev/null +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticEntries/TokenIssueCountDiagnosticEntry.cs @@ -0,0 +1,186 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable +using System.Diagnostics.Metrics; +using System.Text.Json; +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; + +internal class TokenIssueCountDiagnosticEntry : IDiagnosticEntry +{ + private long _jwtTokenIssued; + private long _referenceTokenIssued; + private long _refreshTokenIssued; + private long _jwtPoPDPoPTokenIssued; + private long _referencePoPDPoPTokenIssued; + private long _jwtPoPmTLSTokenIssued; + private long _referencePoPmTLSTokenIssued; + private long _idTokenIssued; + + private long _implicitGrantTypeFlows; + private long _hybridGrantTypeFlows; + private long _authorizationCodeGrantTypeFlows; + private long _clientCredentialsGrantTypeFlows; + private long _resourceOwnerPasswordGrantTypeFlows; + private long _deviceFlowGrantTypeFlows; + private long _otherGrantTypeFlows; + + private readonly MeterListener _meterListener; + + public TokenIssueCountDiagnosticEntry() + { + _meterListener = new MeterListener(); + + _meterListener.InstrumentPublished += (instrument, listener) => + { + if (instrument.Name == Telemetry.Metrics.Counters.TokenIssued) + { + listener.EnableMeasurementEvents(instrument); + } + }; + + _meterListener.SetMeasurementEventCallback(HandleLongMeasurementRecorded); + + _meterListener.Start(); + } + + public Task WriteAsync(Utf8JsonWriter writer) + { + writer.WritePropertyName("TokenIssueCounts"); + writer.WriteStartObject(); + + writer.WriteNumber("Jwt", _jwtTokenIssued); + writer.WriteNumber("Reference", _referenceTokenIssued); + writer.WriteNumber("JwtPoPDPoP", _jwtPoPDPoPTokenIssued); + writer.WriteNumber("ReferencePoPDPoP", _referencePoPDPoPTokenIssued); + writer.WriteNumber("JwtPoPmTLS", _jwtPoPmTLSTokenIssued); + writer.WriteNumber("ReferencePoPmTLS", _referencePoPmTLSTokenIssued); + writer.WriteNumber("Refresh", _refreshTokenIssued); + writer.WriteNumber("Id", _idTokenIssued); + writer.WriteNumber(GrantType.Implicit, _implicitGrantTypeFlows); + writer.WriteNumber(GrantType.Hybrid, _hybridGrantTypeFlows); + writer.WriteNumber(GrantType.AuthorizationCode, _authorizationCodeGrantTypeFlows); + writer.WriteNumber(GrantType.ClientCredentials, _clientCredentialsGrantTypeFlows); + writer.WriteNumber(GrantType.ResourceOwnerPassword, _resourceOwnerPasswordGrantTypeFlows); + writer.WriteNumber(GrantType.DeviceFlow, _deviceFlowGrantTypeFlows); + writer.WriteNumber("Other", _otherGrantTypeFlows); + + writer.WriteEndObject(); + + return Task.CompletedTask; + } + + private void HandleLongMeasurementRecorded(Instrument instrument, long value, ReadOnlySpan> tags, object? state) + { + if (instrument.Name != Telemetry.Metrics.Counters.TokenIssued) + { + return; + } + + var accessTokenIssued = false; + var accessTokenType = AccessTokenType.Jwt; + var refreshTokenIssued = false; + var proofType = ProofType.None; + var identityTokenIssued = false; + var grantType = string.Empty; + + foreach (var tag in tags) + { + switch (tag.Key) + { + case Telemetry.Metrics.Tags.AccessTokenType: + if (!Enum.TryParse(tag.Value?.ToString(), out accessTokenType)) + { + accessTokenType = AccessTokenType.Jwt; + } + break; + case Telemetry.Metrics.Tags.RefreshTokenIssued: + bool.TryParse(tag.Value?.ToString(), out refreshTokenIssued); + break; + case Telemetry.Metrics.Tags.ProofType: + if (!Enum.TryParse(tag.Value?.ToString(), out proofType)) + { + proofType = ProofType.None; + } + break; + case Telemetry.Metrics.Tags.AccessTokenIssued: + bool.TryParse(tag.Value?.ToString(), out accessTokenIssued); + break; + case Telemetry.Metrics.Tags.IdTokenIssued: + bool.TryParse(tag.Value?.ToString(), out identityTokenIssued); + break; + case Telemetry.Metrics.Tags.GrantType: + grantType = tag.Value?.ToString(); + break; + } + } + + if (accessTokenIssued) + { + switch (proofType) + { + case ProofType.None when accessTokenType == AccessTokenType.Jwt: + Interlocked.Increment(ref _jwtTokenIssued); + break; + case ProofType.None when accessTokenType == AccessTokenType.Reference: + Interlocked.Increment(ref _referenceTokenIssued); + break; + case ProofType.DPoP when accessTokenType == AccessTokenType.Jwt: + Interlocked.Increment(ref _jwtPoPDPoPTokenIssued); + break; + case ProofType.DPoP when accessTokenType == AccessTokenType.Reference: + Interlocked.Increment(ref _referencePoPDPoPTokenIssued); + break; + case ProofType.ClientCertificate when accessTokenType == AccessTokenType.Jwt: + Interlocked.Increment(ref _jwtPoPmTLSTokenIssued); + break; + case ProofType.ClientCertificate when accessTokenType == AccessTokenType.Reference: + Interlocked.Increment(ref _referencePoPmTLSTokenIssued); + break; + } + } + + if (refreshTokenIssued) + { + Interlocked.Increment(ref _refreshTokenIssued); + } + + if (identityTokenIssued) + { + Interlocked.Increment(ref _idTokenIssued); + } + + var tokenWasIssued = accessTokenIssued || refreshTokenIssued || identityTokenIssued; + if (!tokenWasIssued || string.IsNullOrEmpty(grantType)) + { + return; + } + + switch (grantType) + { + case GrantType.Implicit: + Interlocked.Increment(ref _implicitGrantTypeFlows); + break; + case GrantType.Hybrid: + Interlocked.Increment(ref _hybridGrantTypeFlows); + break; + case GrantType.AuthorizationCode: + Interlocked.Increment(ref _authorizationCodeGrantTypeFlows); + break; + case GrantType.ClientCredentials: + Interlocked.Increment(ref _clientCredentialsGrantTypeFlows); + break; + case GrantType.ResourceOwnerPassword: + Interlocked.Increment(ref _resourceOwnerPasswordGrantTypeFlows); + break; + case GrantType.DeviceFlow: + Interlocked.Increment(ref _deviceFlowGrantTypeFlows); + break; + default: + Interlocked.Increment(ref _otherGrantTypeFlows); + break; + } + } +} diff --git a/identity-server/src/Telemetry/Telemetry.cs b/identity-server/src/Telemetry/Telemetry.cs index 70ff513e3..b1380a4a6 100644 --- a/identity-server/src/Telemetry/Telemetry.cs +++ b/identity-server/src/Telemetry/Telemetry.cs @@ -3,6 +3,7 @@ using System.Diagnostics.Metrics; +using Duende.IdentityServer.Models; using Duende.IdentityServer.Validation; namespace Duende.IdentityServer; @@ -65,6 +66,11 @@ public static class Tags public const string Scheme = "scheme"; public const string Result = "result"; public const string Type = "type"; + public const string AccessTokenIssued = "access_token_issued"; + public const string AccessTokenType = "access_token_type"; + public const string RefreshTokenIssued = "refresh_token_issued"; + public const string ProofType = "proof_type"; + public const string IdTokenIssued = "id_token_issued"; } /// @@ -436,12 +442,14 @@ public static void RevocationFailure(string clientId, string error) public static readonly Counter TokenIssuedCounter = Meter.CreateCounter(Counters.TokenIssued); + /// /// Helper method to increase /// /// Client Id /// Grant Type /// Type of authorization request + [Obsolete("This overload will be removed in a future version. Use the overload with accessTokenIssued, accessTokenType, refreshTokenIssued, proofType, and idTokenIssued parameters instead.")] public static void TokenIssued(string clientId, string grantType, AuthorizeRequestType? requestType) { Success(clientId); @@ -451,6 +459,32 @@ public static void TokenIssued(string clientId, string grantType, AuthorizeReque new(Tags.AuthorizeRequestType, requestType)); } + /// + /// Helper method to increase + /// + /// Client Id + /// Grant Type + /// Type of authorization request + /// Whether an access token was issued + /// The type of access token issued (Null if no access token was issued, otherwise JWT or Reference) + /// Whether a refresh token was issued + /// The proof type used (None, ClientCertificate, or DPoP) + /// Whether an id token was issued + public static void TokenIssued(string clientId, string grantType, AuthorizeRequestType? requestType, + bool accessTokenIssued, AccessTokenType? accessTokenType, bool refreshTokenIssued, ProofType proofType, bool idTokenIssued) + { + Success(clientId); + TokenIssuedCounter.Add(1, + new(Tags.Client, clientId), + new(Tags.GrantType, grantType), + new(Tags.AuthorizeRequestType, requestType), + new(Tags.AccessTokenIssued, accessTokenIssued), + new(Tags.AccessTokenType, accessTokenType), + new(Tags.RefreshTokenIssued, refreshTokenIssued), + new(Tags.ProofType, proofType), + new(Tags.IdTokenIssued, idTokenIssued)); + } + /// /// Helper method to increase on errors /// diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/TokenIssueCountDiagnosticEntryTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/TokenIssueCountDiagnosticEntryTests.cs new file mode 100644 index 000000000..59763f74e --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticEntries/TokenIssueCountDiagnosticEntryTests.cs @@ -0,0 +1,203 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Reflection; +using Duende.IdentityServer.Licensing.V2.Diagnostics.DiagnosticEntries; +using Duende.IdentityServer.Models; + +namespace IdentityServer.UnitTests.Licensing.V2.DiagnosticEntries; + +public class TokenIssueCountDiagnosticEntryTests +{ + private readonly TokenIssueCountDiagnosticEntry _subject = new(); + + [Fact] + public async Task Should_Count_JwtAccessToken() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, false, ProofType.None, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + result.RootElement.GetProperty("TokenIssueCounts").GetProperty("Jwt").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Count_JwtReferenceToken() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Reference, false, ProofType.None, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + result.RootElement.GetProperty("TokenIssueCounts").GetProperty("Reference").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Count_JwtDPoPToken() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, false, ProofType.DPoP, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + result.RootElement.GetProperty("TokenIssueCounts").GetProperty("JwtPoPDPoP").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Count_ReferenceDPoPToken() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Reference, false, ProofType.DPoP, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + result.RootElement.GetProperty("TokenIssueCounts").GetProperty("ReferencePoPDPoP").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Count_JwtMTlsToken() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, false, ProofType.ClientCertificate, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + result.RootElement.GetProperty("TokenIssueCounts").GetProperty("JwtPoPmTLS").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Count_ReferenceMTlsToken() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Reference, false, ProofType.ClientCertificate, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + result.RootElement.GetProperty("TokenIssueCounts").GetProperty("ReferencePoPmTLS").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Count_RefreshToken() + { + IssueToken("refresh_token", false, AccessTokenType.Jwt, true, ProofType.None, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + result.RootElement.GetProperty("TokenIssueCounts").GetProperty("Refresh").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Count_IdToken() + { + IssueToken(GrantType.AuthorizationCode, false, AccessTokenType.Jwt, false, ProofType.None, true); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + result.RootElement.GetProperty("TokenIssueCounts").GetProperty("Id").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Handle_Multiple_Token_Types() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, true, ProofType.None, false); + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, false, ProofType.DPoP, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + var tokenIssueCounts = result.RootElement.GetProperty("TokenIssueCounts"); + tokenIssueCounts.GetProperty("Jwt").GetInt64().ShouldBe(1); + tokenIssueCounts.GetProperty("JwtPoPDPoP").GetInt64().ShouldBe(1); + tokenIssueCounts.GetProperty("Refresh").GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Handle_No_Token_Issued() + { + IssueToken(GrantType.AuthorizationCode, false, null, false, ProofType.None, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + var tokenIssueCounts = result.RootElement.GetProperty("TokenIssueCounts"); + tokenIssueCounts.GetProperty("Jwt").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("Reference").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("JwtPoPDPoP").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("JwtPoPmTLS").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("ReferencePoPDPoP").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("ReferencePoPmTLS").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("Refresh").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("Id").GetInt64().ShouldBe(0); + } + + [Fact] + public async Task Should_Handle_Initial_Grant_Type_Count() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, false, ProofType.None, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + var tokenIssueCounts = result.RootElement.GetProperty("TokenIssueCounts"); + tokenIssueCounts.GetProperty(GrantType.AuthorizationCode).GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Handle_Multiple_Grant_Type_Counts() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, false, ProofType.None, false); + IssueToken(GrantType.ClientCredentials, true, AccessTokenType.Jwt, false, ProofType.None, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + var tokenIssueCounts = result.RootElement.GetProperty("TokenIssueCounts"); + tokenIssueCounts.GetProperty(GrantType.AuthorizationCode).GetInt64().ShouldBe(1); + tokenIssueCounts.GetProperty(GrantType.ClientCredentials).GetInt64().ShouldBe(1); + } + + [Fact] + public async Task Should_Handle_Multiple_Grant_Type_Counts_With_Grant_Type() + { + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, false, ProofType.None, false); + IssueToken(GrantType.AuthorizationCode, true, AccessTokenType.Jwt, false, ProofType.None, false); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + var tokenIssueCounts = result.RootElement.GetProperty("TokenIssueCounts"); + tokenIssueCounts.GetProperty(GrantType.AuthorizationCode).GetInt64().ShouldBe(2); + } + + [Fact] + public async Task Should_Handle_Grant_Type_Counts_For_All_Grant_Types() + { + var grantTypes = typeof(GrantType).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(field => field.IsLiteral && !field.IsInitOnly) + .Select(field => field.GetValue(null)?.ToString()) + .Where(value => value != null); + foreach (var grantType in grantTypes) + { + IssueToken(grantType, true, AccessTokenType.Jwt, false, ProofType.None, false); + } + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + var tokenIssueCounts = result.RootElement.GetProperty("TokenIssueCounts"); + foreach (var grantType in grantTypes) + { + tokenIssueCounts.GetProperty(grantType).GetInt64().ShouldBe(1); + } + } + + [Fact] + public async Task Should_Ignore_Non_TokenIssued_Instruments() + { + Duende.IdentityServer.Telemetry.Metrics.TokenIssuedFailure("ClientId", "GrantType", null, "error"); + + var result = await DiagnosticEntryTestHelper.WriteEntryToJson(_subject); + + var tokenIssueCounts = result.RootElement.GetProperty("TokenIssueCounts"); + tokenIssueCounts.GetProperty("Jwt").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("Reference").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("JwtPoPDPoP").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("JwtPoPmTLS").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("ReferencePoPDPoP").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("ReferencePoPmTLS").GetInt64().ShouldBe(0); + tokenIssueCounts.GetProperty("Refresh").GetInt64().ShouldBe(0); + } + + private void IssueToken(string grantType, bool accessTokenIssued, AccessTokenType? accessTokenType, bool refreshTokenIssued, + ProofType proofType, bool idTokenIssued) => + Duende.IdentityServer.Telemetry.Metrics.TokenIssued("ClientId", grantType, null, accessTokenIssued, accessTokenType, refreshTokenIssued, proofType, idTokenIssued); +}