Skip to content

Commit af95034

Browse files
committed
Add OpenTelemetry diagnostics and metrics
1 parent 69db138 commit af95034

9 files changed

Lines changed: 772 additions & 46 deletions

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
<PackageVersion Include="AssemblyMetadata.Generators" Version="2.2.0" />
88
<PackageVersion Include="Bogus" Version="35.6.5" />
99
<PackageVersion Include="coverlet.collector" Version="10.0.0" />
10-
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
10+
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
1111
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
1212
<PackageVersion Include="MinVer" Version="7.0.0" />
1313
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.9" />
1414
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" />
15+
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.7" />
1516
<PackageVersion Include="xunit" Version="2.9.3" />
1617
<PackageVersion Include="XUnit.Hosting" Version="4.0.0" />
1718
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />

README.md

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ A flexible and lightweight API key authentication library for ASP.NET Core appli
1919
- [Usage Patterns](#usage-patterns)
2020
- [Advanced Customization](#advanced-customization)
2121
- [OpenAPI/Swagger Integration](#openapiswagger-integration)
22+
- [OpenTelemetry Support](#opentelemetry-support)
2223
- [Best Practices](#best-practices)
2324
- [Troubleshooting](#troubleshooting)
2425
- [Examples Repository](#examples-repository)
@@ -29,14 +30,15 @@ A flexible and lightweight API key authentication library for ASP.NET Core appli
2930

3031
AspNetCore.SecurityKey provides a complete API key authentication solution for ASP.NET Core applications with support for modern development patterns and best practices.
3132

32-
**Key Features:**
33+
**Feature List:**
3334

3435
- **Multiple Input Sources** - API keys via headers, query parameters, or cookies
3536
- **Flexible Authentication** - Works with ASP.NET Core's built-in authentication or as standalone middleware
3637
- **IP Address Whitelisting** - Restrict API access by IP addresses and network ranges (IPv4 and IPv6)
3738
- **Extensible Design** - Custom validation and extraction logic support
3839
- **Rich Integration** - Controller attributes, middleware, and minimal API support
3940
- **OpenAPI Support** - Automatic Swagger/OpenAPI documentation generation (.NET 9+)
41+
- **OpenTelemetry Support** - Built-in activities, metrics, tags, and exception events for authentication diagnostics
4042
- **High Performance** - Minimal overhead with optional caching and timing-attack protection
4143
- **Multiple Deployment Patterns** - Attribute-based, middleware, or endpoint filters
4244

@@ -248,6 +250,7 @@ IP whitelisting is configured using the enhanced configuration format in `appset
248250
### Common Use Cases
249251

250252
#### Development Environment
253+
251254
Allow only local development machines:
252255

253256
```json
@@ -266,6 +269,7 @@ Allow only local development machines:
266269
```
267270

268271
#### Corporate Environment
272+
269273
Allow only internal corporate networks:
270274

271275
```json
@@ -281,7 +285,6 @@ Allow only internal corporate networks:
281285
}
282286
```
283287

284-
285288
### Reverse Proxy Considerations
286289

287290
When running behind a reverse proxy (like nginx, IIS, or cloud load balancers), ensure proper configuration to get the real client IP:
@@ -310,7 +313,6 @@ app.UseSecurityKey();
310313
4. **Proxy Headers**: Validate that your reverse proxy configuration correctly forwards real client IPs
311314
5. **Logging**: Monitor failed authentication attempts to detect potential security issues
312315

313-
314316
## Usage Patterns
315317

316318
AspNetCore.SecurityKey supports multiple integration patterns to fit different application architectures and security requirements.
@@ -698,6 +700,62 @@ builder.Services.AddSwaggerGen(options =>
698700

699701
The `SecurityKeyDocumentTransformer` automatically configures the OpenAPI specification to include API key authentication requirements, making it easy for developers to understand and test your API.
700702

703+
## OpenTelemetry Support
704+
705+
AspNetCore.SecurityKey emits tracing and metrics using the built-in .NET diagnostics APIs, so applications can export authentication telemetry with OpenTelemetry without adding a separate instrumentation package.
706+
707+
### Instrumentation Names
708+
709+
Use these stable names when configuring OpenTelemetry:
710+
711+
- **ActivitySource**: `AspNetCore.SecurityKey`
712+
- **Meter**: `AspNetCore.SecurityKey`
713+
714+
### Telemetry Features
715+
716+
- **Authentication traces** for middleware, endpoint filter, MVC filter, and authentication handler flows
717+
- **Success and failure result tags** for each authentication attempt
718+
- **Failure reason tags** for invalid API keys and authentication errors
719+
- **Hashed API key tags** for tracking key usage without exposing raw keys
720+
- **Exception events** added to activities when unexpected authentication errors occur
721+
- **Request counter** for total SecurityKey authentication attempts
722+
- **Failure counter** for failed SecurityKey authentication attempts
723+
- **Duration histogram** for authentication latency in milliseconds
724+
725+
### Metrics
726+
727+
| Metric | Unit | Description |
728+
| ------------------------------------- | ---------- | ------------------------------------------------------ |
729+
| `securitykey.authentication.requests` | `requests` | Number of SecurityKey authentication requests handled. |
730+
| `securitykey.authentication.failures` | `failures` | Number of failed SecurityKey authentication requests. |
731+
| `securitykey.authentication.duration` | `ms` | Duration of SecurityKey authentication attempts. |
732+
733+
### Tags
734+
735+
| Tag | Description |
736+
| --------------------------------- | ------------------------------------------------------------------------------ |
737+
| `securitykey.auth.scheme` | Authentication scheme name when using the authentication handler. |
738+
| `securitykey.auth.result` | Authentication result, such as `success` or `failure`. |
739+
| `securitykey.auth.failure_reason` | Failure reason, such as `invalid_client` or `authentication_error`. |
740+
| `securitykey.auth.pattern` | Integration pattern, such as `middleware`, `endpoint_filter`, or `mvc_filter`. |
741+
| `securitykey.api_key.hash` | SHA-256 hash of the API key for tracking usage without exposing the raw key. |
742+
743+
### OpenTelemetry Configuration Example
744+
745+
```csharp
746+
builder.Services.AddOpenTelemetry()
747+
.WithTracing(tracing => tracing
748+
.AddSource("AspNetCore.SecurityKey")
749+
.AddAspNetCoreInstrumentation()
750+
.AddOtlpExporter())
751+
.WithMetrics(metrics => metrics
752+
.AddMeter("AspNetCore.SecurityKey")
753+
.AddAspNetCoreInstrumentation()
754+
.AddOtlpExporter());
755+
```
756+
757+
> The example assumes your application already references the OpenTelemetry packages required by the exporters and instrumentation you choose.
758+
701759
## Best Practices
702760

703761
### Security Considerations
@@ -708,6 +766,7 @@ The `SecurityKeyDocumentTransformer` automatically configures the OpenAPI specif
708766
4. **Rate Limiting**: Implement rate limiting to prevent abuse
709767
5. **IP Whitelisting**: Use IP restrictions for additional security when possible
710768
6. **Timing Attack Protection**: The library uses cryptographic operations to prevent timing attacks
769+
7. **Telemetry Hygiene**: Avoid recording raw API keys in custom telemetry tags or logs
711770

712771
### Configuration Best Practices
713772

@@ -721,6 +780,7 @@ The `SecurityKeyDocumentTransformer` automatically configures the OpenAPI specif
721780
1. **Caching**: Enable caching for authentication results when using custom validators
722781
2. **Connection Pooling**: Use connection pooling for database-backed validators
723782
3. **Async Operations**: Leverage async/await patterns for I/O operations
783+
4. **Observability**: Export SecurityKey traces and metrics with OpenTelemetry to monitor authentication latency and failures
724784

725785
## Troubleshooting
726786

src/AspNetCore.SecurityKey/AspNetCore.SecurityKey.csproj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
@@ -10,6 +10,10 @@
1010
<FrameworkReference Include="Microsoft.AspNetCore.App" />
1111
</ItemGroup>
1212

13+
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
14+
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
15+
</ItemGroup>
16+
1317
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
1418
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
1519
</ItemGroup>

src/AspNetCore.SecurityKey/SecurityKeyAuthenticationHandler.cs

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using System.Security.Claims;
23
using System.Text.Encodings.Web;
34

@@ -38,37 +39,108 @@ public SecurityKeyAuthenticationHandler(
3839
/// </returns>
3940
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
4041
{
41-
// Resolve provider when configured; otherwise use the default registration.
42-
var keyExtractor = string.IsNullOrEmpty(Options.ProviderServiceKey)
43-
? Context.RequestServices.GetRequiredService<ISecurityKeyExtractor>()
44-
: Context.RequestServices.GetRequiredKeyedService<ISecurityKeyExtractor>(Options.ProviderServiceKey);
42+
var startTimestamp = 0L;
43+
Activity? activity = null;
4544

46-
// Extract the security key from the request using the configured extractor
47-
var securityKey = keyExtractor.GetKey(Context);
45+
try
46+
{
47+
// Resolve provider when configured; otherwise use the default registration.
48+
var keyExtractor = string.IsNullOrEmpty(Options.ProviderServiceKey)
49+
? Context.RequestServices.GetRequiredService<ISecurityKeyExtractor>()
50+
: Context.RequestServices.GetRequiredKeyedService<ISecurityKeyExtractor>(Options.ProviderServiceKey);
51+
52+
// Extract the security key from the request using the configured extractor
53+
var securityKey = keyExtractor.GetKey(Context);
54+
55+
// If no security key is provided, return no result
56+
if (string.IsNullOrEmpty(securityKey))
57+
return AuthenticateResult.NoResult();
58+
59+
startTimestamp = Stopwatch.GetTimestamp();
60+
activity = SecurityKeyDiagnostics.ActivitySource.StartActivity(SecurityKeyDiagnostics.AuthenticationActivityName, ActivityKind.Server);
61+
62+
activity?.SetTag(SecurityKeyDiagnostics.AuthenticationSchemeTagName, Scheme.Name);
63+
64+
var ipAddress = keyExtractor.GetRemoteAddress(Context);
65+
66+
// Resolve provider when configured; otherwise use the default registration.
67+
var keyValidator = string.IsNullOrEmpty(Options.ProviderServiceKey)
68+
? Context.RequestServices.GetRequiredService<ISecurityKeyValidator>()
69+
: Context.RequestServices.GetRequiredKeyedService<ISecurityKeyValidator>(Options.ProviderServiceKey);
70+
71+
// Authenticate the security key and get the claims identity
72+
var identity = await keyValidator.Authenticate(securityKey, ipAddress, Scheme.Name, Context.RequestAborted);
73+
var securityKeyHash = SecurityKeyDiagnostics.ComputeSecurityKeyHash(securityKey);
74+
75+
if (!identity.IsAuthenticated)
76+
{
77+
Logger.LogWarning("Invalid security key {SecurityKey} from IP {IPAddress}", securityKey, ipAddress);
78+
79+
return CompleteAuthentication(
80+
result: InvalidSecurityKey,
81+
activity: activity,
82+
startTimestamp: startTimestamp,
83+
authenticationResult: SecurityKeyDiagnostics.AuthenticationResultFailure,
84+
securityKeyHash: securityKeyHash,
85+
failureReason: SecurityKeyDiagnostics.InvalidSecurityKeyFailureReason);
86+
}
4887

49-
// If no security key is provided, return no result
50-
if (string.IsNullOrEmpty(securityKey))
51-
return AuthenticateResult.NoResult();
88+
// create a user claim for the security key
89+
var principal = new ClaimsPrincipal(identity);
90+
var ticket = new AuthenticationTicket(principal, Scheme.Name);
5291

53-
var ipAddress = keyExtractor.GetRemoteAddress(Context);
92+
return CompleteAuthentication(
93+
result: AuthenticateResult.Success(ticket),
94+
activity: activity,
95+
startTimestamp: startTimestamp,
96+
authenticationResult: SecurityKeyDiagnostics.AuthenticationResultSuccess,
97+
securityKeyHash: securityKeyHash);
5498

55-
// Resolve provider when configured; otherwise use the default registration.
56-
var keyValidator = string.IsNullOrEmpty(Options.ProviderServiceKey)
57-
? Context.RequestServices.GetRequiredService<ISecurityKeyValidator>()
58-
: Context.RequestServices.GetRequiredKeyedService<ISecurityKeyValidator>(Options.ProviderServiceKey);
99+
}
100+
catch (Exception ex) when (ex is not OperationCanceledException)
101+
{
102+
activity?.AddException(ex);
103+
104+
CompleteAuthentication(
105+
result: AuthenticateResult.Fail(ex),
106+
activity: activity,
107+
startTimestamp: startTimestamp,
108+
authenticationResult: SecurityKeyDiagnostics.AuthenticationResultFailure,
109+
failureReason: SecurityKeyDiagnostics.AuthenticationErrorFailureReason);
59110

60-
// Authenticate the security key and get the claims identity
61-
var identity = await keyValidator.Authenticate(securityKey, ipAddress, Scheme.Name, Context.RequestAborted);
62-
if (!identity.IsAuthenticated)
111+
throw;
112+
}
113+
finally
63114
{
64-
Logger.LogWarning("Invalid security key {SecurityKey} from IP {IPAddress}", securityKey, ipAddress);
65-
return InvalidSecurityKey;
115+
activity?.Dispose();
66116
}
117+
}
118+
119+
private static AuthenticateResult CompleteAuthentication(
120+
AuthenticateResult result,
121+
Activity? activity,
122+
long startTimestamp,
123+
string authenticationResult,
124+
string? securityKeyHash = null,
125+
string? failureReason = null)
126+
{
127+
activity?.SetTag(SecurityKeyDiagnostics.AuthenticationResultTagName, authenticationResult);
128+
129+
if (securityKeyHash is not null)
130+
activity?.SetTag(SecurityKeyDiagnostics.SecurityKeyHashTagName, securityKeyHash);
131+
132+
if (failureReason is not null)
133+
activity?.SetTag(SecurityKeyDiagnostics.AuthenticationFailureReasonTagName, failureReason);
134+
135+
if (authenticationResult == SecurityKeyDiagnostics.AuthenticationResultFailure)
136+
activity?.SetStatus(ActivityStatusCode.Error, failureReason);
67137

68-
// create a user claim for the security key
69-
var principal = new ClaimsPrincipal(identity);
70-
var ticket = new AuthenticationTicket(principal, Scheme.Name);
138+
SecurityKeyDiagnostics.RecordAuthenticationMetrics(
139+
startTimestamp: startTimestamp,
140+
authenticationResult: authenticationResult,
141+
failureReason: failureReason,
142+
securityKeyHash: securityKeyHash);
71143

72-
return AuthenticateResult.Success(ticket);
144+
return result;
73145
}
74146
}

src/AspNetCore.SecurityKey/SecurityKeyAuthorizationFilter.cs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Diagnostics;
2+
13
using Microsoft.AspNetCore.Mvc;
24
using Microsoft.AspNetCore.Mvc.Filters;
35
using Microsoft.Extensions.Logging;
@@ -48,12 +50,44 @@ public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
4850
{
4951
ArgumentNullException.ThrowIfNull(context);
5052

51-
var securityKey = _securityKeyExtractor.GetKey(context.HttpContext);
52-
var ipAddress = _securityKeyExtractor.GetRemoteAddress(context.HttpContext);
53+
using var activity = SecurityKeyDiagnostics.StartAuthenticationActivity(SecurityKeyDiagnostics.MvcFilterAuthenticationPattern);
54+
var startTimestamp = Stopwatch.GetTimestamp();
55+
56+
try
57+
{
58+
var securityKey = _securityKeyExtractor.GetKey(context.HttpContext);
59+
var ipAddress = _securityKeyExtractor.GetRemoteAddress(context.HttpContext);
60+
61+
if (await _securityKeyValidator.Validate(securityKey, ipAddress))
62+
{
63+
SecurityKeyDiagnostics.CompleteAuthentication(
64+
activity: activity,
65+
startTimestamp: startTimestamp,
66+
authenticationResult: SecurityKeyDiagnostics.AuthenticationResultSuccess,
67+
securityKey: securityKey);
68+
69+
return;
70+
}
71+
72+
_logger.LogWarning("Invalid security key {SecurityKey} from IP {IPAddress}", securityKey, ipAddress);
73+
74+
SecurityKeyDiagnostics.CompleteAuthentication(
75+
activity: activity,
76+
startTimestamp: startTimestamp,
77+
authenticationResult: SecurityKeyDiagnostics.AuthenticationResultFailure,
78+
securityKey: securityKey,
79+
failureReason: SecurityKeyDiagnostics.InvalidSecurityKeyFailureReason);
5380

54-
if (await _securityKeyValidator.Validate(securityKey, ipAddress))
55-
return;
81+
context.Result = new UnauthorizedResult();
82+
}
83+
catch (Exception ex) when (ex is not OperationCanceledException)
84+
{
85+
SecurityKeyDiagnostics.RecordAuthenticationException(
86+
activity: activity,
87+
startTimestamp: startTimestamp,
88+
exception: ex);
5689

57-
context.Result = new UnauthorizedResult();
90+
throw;
91+
}
5892
}
5993
}

0 commit comments

Comments
 (0)