perf(auth): allocation-free token parsing; single partition-key build per RPC

This commit is contained in:
Joseph Doherty
2026-08-15 12:23:25 -04:00
parent 3ff073d1ea
commit 88d38bb900
5 changed files with 221 additions and 25 deletions
@@ -127,16 +127,31 @@ public sealed class ApiKeyFailureLimiter
/// <summary>Decides whether an authentication attempt may reach the verifier.</summary>
/// <param name="partition">The throttle partition derived from the request.</param>
/// <returns>The admission decision for this attempt.</returns>
public ApiKeyThrottleDecision Check(ApiKeyThrottlePartition partition)
public ApiKeyThrottleDecision Check(ApiKeyThrottlePartition partition) => Check(partition, out _);
/// <summary>
/// Decides whether an authentication attempt may reach the verifier, handing back the storage
/// key it resolved so a caller that goes on to <see cref="Reset(ApiKeyThrottlePartition, PartitionResolution)"/>
/// the same request does not resolve — and rebuild the composite key string — a second time.
/// </summary>
/// <param name="partition">The throttle partition derived from the request.</param>
/// <param name="resolution">
/// The resolved storage key, or <see cref="PartitionResolution.Unresolved"/> when the limiter is
/// disabled and never resolved one.
/// </param>
/// <returns>The admission decision for this attempt.</returns>
internal ApiKeyThrottleDecision Check(ApiKeyThrottlePartition partition, out PartitionResolution resolution)
{
string peer = RequirePeer(partition);
if (_limit <= 0)
{
resolution = PartitionResolution.Unresolved;
return ApiKeyThrottleDecision.Allowed;
}
long now = _clock.GetUtcNow().UtcTicks;
(string partitionKey, string? effectiveKeyId) = ResolvePartitionKey(peer, partition.KeyId, mint: false);
resolution = new PartitionResolution(partitionKey, effectiveKeyId);
WindowState? peerState = _partitions.TryGetValue(partitionKey, out WindowState? tracked) ? tracked : null;
WindowState? aggregateState = null;
@@ -221,10 +236,25 @@ public sealed class ApiKeyFailureLimiter
/// <summary>Clears both limiter layers for the partition after a successful verification.</summary>
/// <param name="partition">The throttle partition derived from the request.</param>
public void Reset(ApiKeyThrottlePartition partition)
public void Reset(ApiKeyThrottlePartition partition) => Reset(partition, PartitionResolution.Unresolved);
/// <summary>
/// Clears both limiter layers for the partition after a successful verification, reusing the
/// storage key <see cref="Check(ApiKeyThrottlePartition, out PartitionResolution)"/> already
/// resolved for this request.
/// </summary>
/// <param name="partition">The throttle partition derived from the request.</param>
/// <param name="resolution">
/// The resolution handed back by <c>Check</c>; <see cref="PartitionResolution.Unresolved"/>
/// resolves here instead. Reusing the check-time resolution is deliberate: it is the partition
/// this request was admitted against, so the reset clears exactly what the check consulted.
/// </param>
internal void Reset(ApiKeyThrottlePartition partition, PartitionResolution resolution)
{
string peer = RequirePeer(partition);
(string partitionKey, string? effectiveKeyId) = ResolvePartitionKey(peer, partition.KeyId, mint: false);
(string partitionKey, string? effectiveKeyId) = resolution.IsResolved
? (resolution.PartitionKey!, resolution.EffectiveKeyId)
: ResolvePartitionKey(peer, partition.KeyId, mint: false);
// Clear only a partition this caller actually owns. When its key id was squeezed into the
// address's shared fallback bucket by the per-peer cap, that bucket also holds failures
@@ -542,6 +572,25 @@ public sealed class ApiKeyFailureLimiter
public long ProbeVersion;
}
/// <summary>
/// A partition's resolved storage key, carried from the check to the reset of the same request.
/// The composite key is a fresh string per build, so resolving once per RPC rather than once per
/// call keeps the successful auth path (check, then reset) to a single allocation.
/// </summary>
/// <param name="PartitionKey">The storage key, or <see langword="null"/> when unresolved.</param>
/// <param name="EffectiveKeyId">
/// The key id that actually earned a partition, or <see langword="null"/> when the token carried
/// none or the per-peer cap collapsed it onto the transport-peer fallback.
/// </param>
internal readonly record struct PartitionResolution(string? PartitionKey, string? EffectiveKeyId)
{
/// <summary>Gets the sentinel for "not resolved yet"; the receiving call resolves it itself.</summary>
internal static PartitionResolution Unresolved => default;
/// <summary>Gets a value indicating whether this carries a resolved storage key.</summary>
internal bool IsResolved => PartitionKey is not null;
}
/// <summary>A probe slot reservation: what to restore, and the stamp proving it is still ours.</summary>
/// <param name="PreviousProbeAtTicks">The slot value replaced when the claim was made.</param>
/// <param name="Version">The <see cref="WindowState.ProbeVersion"/> stamped by this claim.</param>
@@ -77,8 +77,13 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
// aggregate for the key id. An over-limit state still admits one probe per interval, so the
// holder of the correct secret always reaches the verifier and resets the state.
// ResourceExhausted signals throttling without revealing whether any secret was valid.
//
// The check hands back the storage key it resolved so the reset below reuses it instead of
// rebuilding the composite (peer, key id) string a second time on every successful RPC.
ApiKeyThrottlePartition throttlePartition = ResolveThrottlePartition(authorizationHeader, context);
ApiKeyThrottleDecision decision = failureLimiter.Check(throttlePartition);
ApiKeyThrottleDecision decision = failureLimiter.Check(
throttlePartition,
out ApiKeyFailureLimiter.PartitionResolution throttleResolution);
if (decision is ApiKeyThrottleDecision.ThrottledByPeer or ApiKeyThrottleDecision.ThrottledByAggregate)
{
metrics.RecordAuthThrottled(
@@ -107,7 +112,7 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
// fat-fingered a few attempts is not penalised once it recovers — and, because the check
// above admits a probe rather than blocking absolutely, this reset stays reachable while the
// key is under an active spray.
failureLimiter.Reset(throttlePartition);
failureLimiter.Reset(throttlePartition, throttleResolution);
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
@@ -137,7 +142,11 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
// before a key-id partition is minted so a spray of invented tokens cannot mint one tracked
// partition each and flush the limiter's bounded map (SEC-32). Anything that fails the check
// falls back to the sender's transport-peer partition.
private static string? TryResolveKeyId(string? authorizationHeader)
//
// Internal rather than private so the parse rules can be pinned directly by test; the shape
// check is a security boundary (SEC-32) and is worth asserting without routing every case
// through a full RPC.
internal static string? TryResolveKeyId(string? authorizationHeader)
{
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
@@ -150,22 +159,36 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
? header[bearer.Length..].Trim()
: header;
string[] parts = token.ToString().Split('_');
if (parts.Length < 3)
// Scanned rather than split: this runs on every authenticated RPC, and Split would copy the
// token out of the header and allocate an array plus a string per segment to reach a key id
// that is then usually a dictionary-lookup miss. Two IndexOf scans reach the same answer and
// allocate only the key id itself.
const string prefix = AuthStoreServiceCollectionExtensions.TokenPrefix;
if (!token.StartsWith(prefix, StringComparison.Ordinal)
|| token.Length <= prefix.Length
|| token[prefix.Length] != '_')
{
// Guards the whole first segment, not just its start: the '_' immediately after the
// prefix is what makes "mxgw" the entire segment, so "mxgwabc_..." is still rejected.
return null;
}
ReadOnlySpan<char> afterPrefix = token[(prefix.Length + 1)..];
int separator = afterPrefix.IndexOf('_');
if (separator <= 0 || separator > MaxKeyIdLength)
{
// -1 is a token with no second separator (too few segments); 0 is an empty key id.
return null;
}
// The third segment must be non-empty, which the split form expressed as parts[2].Length: it
// ends at the NEXT separator, so a secret beginning with '_' fails the same way it always did.
ReadOnlySpan<char> afterKeyId = afterPrefix[(separator + 1)..];
if (afterKeyId.IsEmpty || afterKeyId[0] == '_')
{
return null;
}
if (!string.Equals(parts[0], AuthStoreServiceCollectionExtensions.TokenPrefix, StringComparison.Ordinal))
{
return null;
}
if (parts[1].Length == 0 || parts[1].Length > MaxKeyIdLength || parts[2].Length == 0)
{
return null;
}
return parts[1];
return new string(afterPrefix[..separator]);
}
}