Skip to content

Add telemetry around signature validation#3410

Open
iNinja wants to merge 4 commits intodevfrom
iinglese/add-telemetry-around-signature-validation
Open

Add telemetry around signature validation#3410
iNinja wants to merge 4 commits intodevfrom
iinglese/add-telemetry-around-signature-validation

Conversation

@iNinja
Copy link
Contributor

@iNinja iNinja commented Feb 4, 2026

This pull request introduces telemetry tracking for JWT and SAML/2 signature validation in the JsonWebTokenHandler and related classes. It refactors several methods to non-static to support instance-level telemetry. The changes enable detailed telemetry reporting on signature validation outcomes, such as missing keys, unsupported algorithms, and verification failures.

Telemetry integration for JWT and SAML signature validation:

  • Added telemetry reporting to all major signature validation paths in JsonWebTokenHandler, SamlSecurityTokenHandler, and Saml2SecurityTokenHandler, capturing events like signing key not found, algorithm not supported, signature provider creation failure, and signature verification failure or success. This is accomplished by calling the new RecordSignatureValidationTelemetry helper at appropriate points.
  • Refactored ValidateSignature, ValidateSignatureUsingAllKeys, and ValidateSignatureWithKey methods from static to instance methods, allowing access to the instance telemetry client (_telemetryClient).
  • Updated the signature validation logic to pass the telemetry client through the call stack, ensuring telemetry is recorded for all signature validation attempts, including when using all keys or a specific key.

These changes lay the groundwork for comprehensive telemetry on signature validation, improving observability and diagnostics for token validation scenarios.

New Telemetry Counter

Signature Validation Counter

The counter tracks 5 dimensions (tags):

Tag Name Tag Key Purpose Value Source
Library Version IdentityModelVersion Library version tracking IdentityModelTelemetryUtil.ClientVer
Algorithm Algorithm Signature algorithm from token header Token alg claim
Key Algorithm KeyAlgorithm Key type and size identifier CryptoTelemetry.GetKeyAlgorithmId(SecurityKey)
Issuer Issuer Token issuer (allowlisted hosts only) CryptoTelemetry.GetTrackedIssuerOrOther(issuer)
Error Error Error type or success indicator TelemetryConstants.SignatureValidationErrors.*

Tag Cardinality Breakdown

1. IdentityModelVersion

  • Cardinality: Low (~5-10 active versions)
  • Values: Semantic versions (e.g., "7.3.1", "8.0.0")
  • Typical Production: 1-3 versions during rollouts
  • Lifecycle: Reduces over time as deployments converge

2. Algorithm

  • Cardinality: ~12-15
  • Possible Values (from JWT/SAML specs):
Algorithm Family Algorithms Count
RSA (PKCS#1 v1.5) RS256, RS384, RS512 3
RSA-PSS PS256, PS384, PS512 3
ECDSA ES256, ES384, ES512, ES256K 4
HMAC HS256, HS384, HS512 3
Legacy/Rare none, custom algorithms ~2-5
  • Typical Production: 2-5 algorithms (commonly RS256, ES256, PS256)
  • Bounded: JWT/JOSE specs define a finite set of algorithms

3. KeyAlgorithm

  • Cardinality: ~11-13
  • Implementation: CryptoTelemetry.GetKeyAlgorithmId(SecurityKey) returns predefined constants
  • Possible Values:
Key Type Key Algorithm IDs Description
RSA RSA-2048, RSA-3072, RSA-4096, RSA-UNKNOWN RSA public/private keys
ECDSA ECDSA-P256, ECDSA-P384, ECDSA-P521, ECDSA-UNKNOWN Elliptic curve keys
Symmetric SYM-128, SYM-192, SYM-256, SYM-384, SYM-512, SYM-UNKNOWN HMAC shared secrets
Special NO-KEY, UNKNOWN Missing key or unsupported key type
  • Typical Production: 3-6 key types (commonly RSA-2048, RSA-4096, ECDSA-P256)
  • Bounded: Fixed set of industry-standard key sizes

4. Issuer

  • Cardinality: Low (~5-20 tracked hosts)
  • Implementation: CryptoTelemetry.GetTrackedIssuerOrOther(issuer) with allowlist-based filtering
  • Behavior:
    • Extracts host from issuer URI (e.g., https://login.microsoftonline.com/tenant/login.microsoftonline.com)
    • Returns host if in CryptoTelemetry.TrackedIssuers allowlist
    • Returns "other" for all non-allowlisted issuers
  • Typical Values:
    • login.microsoftonline.com (Microsoft Entra ID)
    • accounts.google.com (Google)
    • appleid.apple.com (Apple)
    • other (catch-all for non-tracked issuers)
  • Cardinality Control: Allowlist prevents unbounded growth from arbitrary issuers
  • Default: Empty allowlist (all issuers reported as "other")

5. Error

  • Cardinality: 6 fixed values
  • Implementation: TelemetryConstants.SignatureValidationErrors.*
  • Possible Values:
Error Type Constant Meaning
Success None Signature validation succeeded
Verification Failed SignatureVerificationFailed Signature invalid (key present, crypto works, but signature doesn't match)
Algorithm Not Supported AlgorithmNotSupported Algorithm not supported by key or crypto provider
Provider Creation Failed SignatureProviderCreationFailed Crypto provider could not create signature provider
Signing Key Not Found SigningKeyNotFound No signing key was found or resolved
Other Other Other errors not covered by specific categories
  • Typical Production: 2-4 error types observed (commonly None, SignatureVerificationFailed, SigningKeyNotFound)
  • Bounded: Fixed set of error constants to prevent cardinality explosion

Total Cardinality Calculation

Total Combinations = IdentityModelVersion × Algorithm × KeyAlgorithm × Issuer × Error
                   = 5 × 15 × 13 × 20 × 6
                   = 117,000 theoretical maximum (with full issuer allowlist)

Production Reality (Empty Issuer Allowlist - Default):

Active Versions × Active Algorithms × Active Key Types × Issuer ("other" only) × Active Errors
= 2 × 4 × 4 × 1 × 3
= 96 typical active time series

Production Reality (With 5 Tracked Issuers):

Active Versions × Active Algorithms × Active Key Types × (Tracked Issuers + "other") × Active Errors
= 2 × 4 × 4 × 6 × 3
= 576 typical active time series

Upper Bound Estimate:

  • Default (no issuer tracking): ~150-250 time series
  • With issuer tracking (5-10 issuers): ~1,000-2,000 time series

Note: The issuer dimension is strictly controlled via the CryptoTelemetry.TrackedIssuers allowlist, preventing unbounded cardinality growth.

Cardinality Assessment

Low-Medium Cardinality - Safe for production at scale

Rationale:

  1. ✅ Issuer dimension is strictly allowlist-controlled (default: all issuers → "other")
  2. ✅ Error dimension is a fixed enumeration (6 values)
  3. ✅ Algorithm set is finite and standardized
  4. ✅ Key sizes are industry-standard values (not arbitrary)
  5. ✅ Library versions naturally consolidate over time

…terface and TelemetryClient to support the new telemetry. Added null telemetry client for no op
…exceptions to identify the stage at which the signature validation failed.
@iNinja iNinja requested a review from a team as a code owner February 4, 2026 21:57
@iNinja iNinja requested a review from Copilot February 4, 2026 22:51
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request introduces comprehensive telemetry tracking for JWT and SAML signature validation operations. The changes add instrumentation to capture detailed metrics about signature validation outcomes including algorithm types, key sizes, issuers, and error types. Key changes include refactoring static signature validation methods to instance methods to support telemetry client access.

Changes:

  • Added CryptoTelemetry utility class with methods for extracting key algorithm identifiers and managing tracked issuer allowlists
  • Introduced signature validation telemetry counters with 5 dimensions (library version, algorithm, key algorithm, issuer, error type)
  • Refactored signature validation methods from static to instance methods across JsonWebTokenHandler, JwtSecurityTokenHandler, and SAML handlers
  • Added comprehensive test coverage for telemetry functionality across JWT and SAML handlers

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs New utility class for telemetry key/issuer identification with cardinality controls
src/Microsoft.IdentityModel.Tokens/Telemetry/TelemetryConstants.cs Added signature validation error constants and telemetry tag definitions
src/Microsoft.IdentityModel.Tokens/Telemetry/TelemetryClient.cs Added IncrementSignatureValidationCounter method for recording signature validation metrics
src/Microsoft.IdentityModel.Tokens/Telemetry/ITelemetryClient.cs Extended interface with signature validation counter method
src/Microsoft.IdentityModel.Tokens/Telemetry/NullTelemetryClient.cs New no-op telemetry client implementation for testing
src/Microsoft.IdentityModel.Tokens/Telemetry/TelemetryDataRecorder.cs Added signature validation counter instrument
src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.ValidateToken.cs Refactored to instance methods, added telemetry recording at all signature validation paths
src/Microsoft.IdentityModel.JsonWebTokens/Experimental/JsonWebTokenHandler.ValidateSignature.cs Added telemetry recording in experimental validation API
src/System.IdentityModel.Tokens.Jwt/JwtSecurityTokenHandler.cs Added telemetry recording, fixed conditional compilation logic for key unwrapping
src/Microsoft.IdentityModel.Tokens.Saml/ Added telemetry recording in both SAML and SAML2 handlers
test/Microsoft.IdentityModel.Tokens.Tests/Telemetry/ Comprehensive test coverage for telemetry functionality
test/Microsoft.IdentityModel.TestUtils/KeyingMaterial.cs Added CreateRsa helper for cross-platform RSA key creation
src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt Added public API surface for CryptoTelemetry class

Comment on lines +54 to +58
// Only return measurements that were recorded after this listener started
return _measurements
.Where(m => m.InstrumentName == instrumentName && m.Timestamp >= _startTimestamp)
.ToList();
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timestamp filtering logic assumes that measurements recorded before the listener was created should be filtered out. However, Stopwatch.GetTimestamp() returns ticks in an arbitrary time base, and the comparison m.Timestamp >= _startTimestamp could fail if measurements are recorded in a different order than expected or if the system's high-resolution timer wraps. Consider documenting this assumption or using a more robust filtering mechanism, such as clearing measurements on listener creation.

Copilot uses AI. Check for mistakes.

internal static bool ValidateSignature(JsonWebToken jsonWebToken, SecurityKey key, TokenValidationParameters validationParameters)
{
return ValidateSignature(jsonWebToken, key, validationParameters, new Telemetry.NullTelemetryClient());
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new instance of NullTelemetryClient is created every time this method is called. Since NullTelemetryClient has a singleton instance (NullTelemetryClient.Instance), it would be more efficient to use that instead: NullTelemetryClient.Instance.

Suggested change
return ValidateSignature(jsonWebToken, key, validationParameters, new Telemetry.NullTelemetryClient());
return ValidateSignature(jsonWebToken, key, validationParameters, Telemetry.NullTelemetryClient.Instance);

Copilot uses AI. Check for mistakes.
var result = CryptoTelemetry.TrackedIssuers;

// Assert
Assert.Equal(3, result.Length); // Only non-null/non-empty entries
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test asserts Assert.Equal(3, result.Length) but only provides 2 non-null/non-empty entries ("issuer1.com" and "issuer2.com"). The entry " " (whitespace) is not filtered out by the string.IsNullOrEmpty check in the TrackedIssuers setter - it only filters null or empty strings. This test appears to be incorrect and will fail. The setter should use string.IsNullOrWhiteSpace instead of string.IsNullOrEmpty, or the test expectation should be adjusted to expect 3 entries (including the whitespace one).

Suggested change
Assert.Equal(3, result.Length); // Only non-null/non-empty entries
Assert.Equal(2, result.Length); // Only valid (non-null/non-whitespace) entries

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +32
public SignatureValidationTelemetryTests()
{
CryptoTelemetry.RecordSignatureValidationTelemetry = true;
CryptoTelemetry.TrackedIssuers = new[] { ExpectedIssuer };
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor sets global state (CryptoTelemetry.RecordSignatureValidationTelemetry and CryptoTelemetry.TrackedIssuers) which could affect other tests. Consider using a cleanup/disposal pattern to reset this state after tests complete, or document that tests must run serially.

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +28
public class SamlSignatureValidationTelemetryTests
{
const string ExpectedIssuer = "Default.Issuer.com";

public SamlSignatureValidationTelemetryTests()
{
CryptoTelemetry.RecordSignatureValidationTelemetry = true;
CryptoTelemetry.TrackedIssuers = new string[] { ExpectedIssuer };
}

Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor sets global state (CryptoTelemetry.RecordSignatureValidationTelemetry and CryptoTelemetry.TrackedIssuers) which could affect other tests. Consider using a cleanup/disposal pattern to reset this state after tests complete, or document that tests must run serially.

Suggested change
public class SamlSignatureValidationTelemetryTests
{
const string ExpectedIssuer = "Default.Issuer.com";
public SamlSignatureValidationTelemetryTests()
{
CryptoTelemetry.RecordSignatureValidationTelemetry = true;
CryptoTelemetry.TrackedIssuers = new string[] { ExpectedIssuer };
}
public class SamlSignatureValidationTelemetryTests : IDisposable
{
const string ExpectedIssuer = "Default.Issuer.com";
private readonly bool _originalRecordSignatureValidationTelemetry;
private readonly string[] _originalTrackedIssuers;
public SamlSignatureValidationTelemetryTests()
{
_originalRecordSignatureValidationTelemetry = CryptoTelemetry.RecordSignatureValidationTelemetry;
_originalTrackedIssuers = CryptoTelemetry.TrackedIssuers;
CryptoTelemetry.RecordSignatureValidationTelemetry = true;
CryptoTelemetry.TrackedIssuers = new string[] { ExpectedIssuer };
}
public void Dispose()
{
CryptoTelemetry.RecordSignatureValidationTelemetry = _originalRecordSignatureValidationTelemetry;
CryptoTelemetry.TrackedIssuers = _originalTrackedIssuers;
}

Copilot uses AI. Check for mistakes.
Comment on lines +1932 to +1935
else if (key.CryptoProviderFactory.IsSupportedAlgorithm(jwtToken.Header.Alg, key))
#else
if (key.CryptoProviderFactory.IsSupportedAlgorithm(jwtToken.Header.Alg, key))
#endif
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional compilation directive nesting has been changed. The original code had an else at line 1932 without the condition check, but now it includes the condition check if (key.CryptoProviderFactory.IsSupportedAlgorithm(jwtToken.Header.Alg, key)). This changes the logic: previously, for non-.NET472/.NET6_0_OR_GREATER targets, the algorithm support check happened unconditionally. Now it's explicitly checked. While this appears to be a bug fix making the logic consistent, it's a behavioral change that should be verified and documented.

Copilot uses AI. Check for mistakes.
private IEqualityComparer<SamlSubject> _samlSubjectEqualityComparer = new SamlSubjectEqualityComparer();
private SamlSerializer _serializer = new SamlSerializer();

internal Microsoft.IdentityModel.Telemetry.ITelemetryClient _telemetryClient = new Microsoft.IdentityModel.Telemetry.TelemetryClient();
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field '_telemetryClient' can be 'readonly'.

Suggested change
internal Microsoft.IdentityModel.Telemetry.ITelemetryClient _telemetryClient = new Microsoft.IdentityModel.Telemetry.TelemetryClient();
internal readonly Microsoft.IdentityModel.Telemetry.ITelemetryClient _telemetryClient = new Microsoft.IdentityModel.Telemetry.TelemetryClient();

Copilot uses AI. Check for mistakes.
private Saml2Serializer _serializer = new Saml2Serializer();
private string _actorClaimName = DefaultActorClaimName;

internal Telemetry.ITelemetryClient _telemetryClient = new Telemetry.TelemetryClient();
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field '_telemetryClient' can be 'readonly'.

Suggested change
internal Telemetry.ITelemetryClient _telemetryClient = new Telemetry.TelemetryClient();
internal readonly Telemetry.ITelemetryClient _telemetryClient = new Telemetry.TelemetryClient();

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +75
if (value == null || value.Length == 0)
{
_trackedIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
else
{
_trackedIssuers = new HashSet<string>(
value.Where(h => !string.IsNullOrEmpty(h)),
StringComparer.OrdinalIgnoreCase);
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.

Suggested change
if (value == null || value.Length == 0)
{
_trackedIssuers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
else
{
_trackedIssuers = new HashSet<string>(
value.Where(h => !string.IsNullOrEmpty(h)),
StringComparer.OrdinalIgnoreCase);
}
var issuers = (value == null || value.Length == 0)
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(
value.Where(h => !string.IsNullOrEmpty(h)),
StringComparer.OrdinalIgnoreCase);
_trackedIssuers = issuers;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant