Skip to content

OAuth authentication for the gRPC reader and sink - #257

Merged
realtonyyoung merged 67 commits into
masterfrom
claude-tyoung/replicator-oauth-auth-e66296
Oct 6, 2026
Merged

realtonyyoung merged 67 commits into
masterfrom
claude-tyoung/replicator-oauth-auth-e66296

Conversation

@realtonyyoung

@realtonyyoung realtonyyoung commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Added: OAuth 2.0 authentication for the gRPC reader and sink (client credentials or token file), configured independently per side, with automatic token refresh and recovery from token failures
Added: Helm values for extra env, env-from, volumes, volume mounts, service account and pod labels (secrets and Azure Workload Identity)
Changed: Connection string and auth setting values are masked as *** when environment variables are printed at startup
Fixed: The realtime scavenge subscription resubscribes reliably after drops, and its metadata cache is rebuilt after a gap
Fixed: Replication no longer hangs on shutdown when the writer has stopped, and the metrics reporter survives a failed position read
Fixed: Transient sink write failures (e.g. the target node restarting) are retried with backoff until they succeed instead of stopping the writer for good, and a writer that fails permanently now exits the process non-zero so it can be restarted (DEV-76)
Fixed: Scavenge no longer stalls replication on streams that have metadata but no events, such as KurrentDB 26 system streams (DEV-1903)

Why

A customer runs KurrentDB with OAuth (Microsoft Entra ID), which turns off basic username/password authentication. Replicator could only authenticate with user:pass@ in the connection string, so it couldn't replicate to or from that cluster.

What this does

  • Each side (replicator.reader, replicator.sink) gets an optional auth section. type is connectionString (the default, behaves as before), oauthClientCredentials or oauthTokenFile. Any combination works, e.g. a basic-auth source and an OAuth target.
  • Client credentials work with any OAuth 2.0 provider. The client can authenticate with a client secret, a secret file or a JWT client assertion file (RFC 7523, which covers Entra Workload Identity with no secret). additionalParameters covers providers that need audience or resource.
  • Replicator gets a token before every gRPC call and passes it as that call's credentials. Token failures, whether the token endpoint is down or KurrentDB rejects the token, pause replication with rate-limited warnings and recover without a restart. A rejected token is quarantined for 60 s and then re-tested by one call only.
  • Tokens, secrets, assertions and provider error text never reach logs or exception messages.
  • Startup fails fast on bad settings, naming the side. For example, OAuth can't be combined with user:pass@ or tls=false, and is gRPC only.
  • Docs: a new Authentication page, the configuration reference, Kubernetes (Helm values plus an OAuth example) and Docker (env vars) pages, and the changelog.

These existing bugs are fixed too, because token failures would have triggered them. They apply to all modes:

  • The Realtime subscription could stay down after a failed resubscribe, or open twice.
  • The scavenge metadata cache could serve stale values after a subscription gap. For OAuth readers it now fails closed instead of copying events when an auth error stops it reading metadata.
  • Shutdown could hang when the writer had stopped.
  • The metrics reporter stopped for good after one failed read.

Design spec: docs/superpowers/specs/2026-10-03-oauth-authentication-design.md (reviewed with the Codex spec-review flow). Implementation plan: docs/superpowers/plans/2026-10-03-oauth-authentication.md.

Also in this PR: DEV-76 (writer stops for good after a brief sink outage)

Found during local end-to-end testing. When the sink node was briefly unreachable, SinkPipe's ~0.5 s retry ran out, the writer task faulted and was never restarted, and replication stalled until a process restart.

  • GrpcEventWriter now retries transient errors (Unavailable, DeadlineExceeded, ResourceExhausted, Aborted, network errors) with backoff (1 s, 2 s, 4 s … capped at 30 s) until they succeed or shutdown. Warnings are rate-limited and the error detail is sanitized.
  • If the writer dies anyway (a non-transient error), Replicate stops and throws instead of looping. With restartOnFailure: true (the default) the process exits with code 1, so Kubernetes or Docker restarts it.

Local end-to-end test bed

compose/oauth/ runs IdentityServer 4 (the IdP used by KurrentDB's own OAuth tests) and three KurrentDB 26.1.1 nodes (basic, OAuth, OAuth), with scenario configs for running Replicator from source. See its README. The license key goes in a git-ignored .env.

Results against a licensed KurrentDB 26.1.1:

  • Basic → OAuth sink (client credentials): 1,200 events over ~20 min, about 4 token lifetimes, with no failures.
  • OAuth → basic, and OAuth → OAuth (token file rotated every 60 s): all events delivered.
  • Token endpoint down for 6.5 min: Replicator paused, then recovered with no restart.
  • Wrong audience: retries settled at every 30 s, then recovered once the audience was fixed.
  • No role on the reader: PermissionDenied warnings, and replication doesn't start.
  • Sink node restarted mid-run (DEV-76): recovered in 2 s.

Scavenge was off for the earlier runs because of a pre-existing bug that stalls replication on KurrentDB 26 system streams (DEV-1903). DEV-1903 is now fixed in this PR, and scenario 1 was re-run with scavenge on: a full replay plus new events, with no errors.

Testing

  • 144 new unit tests in Kurrent.Replicator.Tests.Auth, covering token sources, quarantine and probe rules, the per-call header through the real EventStoreClient against a fake HTTP/2 handler, writer and reader recovery, Realtime races, pipeline shutdown, config binding and redaction. They passed 3 times in a row locally.
  • Release solution build: 0 warnings.
  • helm template: default output unchanged byte for byte; the populated values render correctly.
  • The existing container tests time out locally on this ARM machine, on master as well (emulated image boot). CI will confirm them.
  • Manual end-to-end against a licensed KurrentDB with the OAuth plugin and a real Entra ID tenant is still to do. The scenarios are listed in the spec under "Manual end-to-end".

🤖 Generated with Claude Code

realtonyyoung and others added 30 commits October 3, 2026 08:04
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rrors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
realtonyyoung and others added 4 commits October 3, 2026 11:37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an exception

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ack hook

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…age version and masking

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Deploying replicator with  Cloudflare Pages  Cloudflare Pages

Latest commit: 912dfb5
Status: ✅  Deploy successful!
Preview URL: https://840b80ba.replicator.pages.dev
Branch Preview URL: https://claude-tyoung-replicator-oau.replicator.pages.dev

View logs

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add OAuth authentication to the gRPC reader and sink

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add independent OAuth client-credentials or token-file authentication for each gRPC replication
 side.
• Refresh tokens and recover from authentication failures while masking sensitive startup output.
• Repair subscription recovery, cache freshness, and shutdown behavior; document and test the
 workflows.
Diagram

graph TD
  Config["Per-side config"] --> Validator["Auth validation"] --> Source["Token source"] --> Context["Auth context"] --> Reader["gRPC reader"] --> Cache["Scavenge cache"]
  Context --> Writer["gRPC sink"]
  Source --> Provider["OAuth provider"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use an OAuth client library
  • ➕ Could reduce custom token-request and response-parsing code.
  • ➖ May add a substantial dependency and complicate token-file and provider-specific support.
  • ➖ Would not remove the need for per-call leases, rejection handling, or subscription recovery.
2. Use only the gRPC credential callback
  • ➕ Would centralize header creation and require fewer call-site changes.
  • ➖ Would not reliably attribute server rejection to the token used by a call.
  • ➖ Would make quarantine and single-call probing harder to enforce.

Recommendation: Keep per-call credentials and leases: they support independent sides and make rejection recovery attributable. Review token-state concurrency and exception redaction closely; neither alternative removes those requirements.

Files changed (64) +9684 / -134

Enhancement (23) +1236 / -105
AccessTokenLease.csDefine token leases and their source contract +16/-0

Define token leases and their source contract

• Introduces generation-tagged tokens and source methods for acquisition, rejection, and acceptance reports.

src/Kurrent.Replicator.KurrentDb/Auth/AccessTokenLease.cs

AuthFailure.csClassify authentication and authorization errors +34/-0

Classify authentication and authorization errors

• Recognizes retryable token failures and permission denials through nested and gRPC exceptions.

src/Kurrent.Replicator.KurrentDb/Auth/AuthFailure.cs

ClientCredentialsTokenSource.csCache and refresh client-credentials tokens +162/-0

Cache and refresh client-credentials tokens

• Shares concurrent refreshes, refreshes ahead of expiry, supports cooldowns and usable-token fallback, and invalidates rejected leases.

src/Kurrent.Replicator.KurrentDb/Auth/ClientCredentialsTokenSource.cs

GrpcAuthContext.csCoordinate credentials for individual gRPC calls +85/-0

Coordinate credentials for individual gRPC calls

• Acquires token leases, retries token failures with backoff, reports outcomes, and links acquisition to shutdown.

src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthContext.cs

GrpcAuthOptions.csModel typed gRPC authentication options +31/-0

Model typed gRPC authentication options

• Defines authentication modes, client-authentication methods, token-source options, and secret-safe formatting.

src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthOptions.cs

GrpcAuthOptionsValidator.csValidate OAuth settings before connecting +101/-0

Validate OAuth settings before connecting

• Rejects insecure or conflicting settings and invalid source options; warns about options ignored in connection-string mode.

src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthOptionsValidator.cs

GrpcAuthSettings.csBind per-side auth configuration +60/-0

Bind per-side auth configuration

• Converts YAML or environment settings into typed options with defaults and side-specific errors.

src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthSettings.cs

GrpcAuthentication.csWire token sources into gRPC client settings +44/-0

Wire token sources into gRPC client settings

• Creates the configured source and installs fallback credentials for client-library calls without per-call credentials.

src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthentication.cs

OAuthTokenException.csIdentify token acquisition failures +7/-0

Identify token acquisition failures

• Adds an exception type for unavailable, invalid, or quarantined tokens.

src/Kurrent.Replicator.KurrentDb/Auth/OAuthTokenException.cs

RateLimitedWarning.csThrottle recurring authentication warnings +23/-0

Throttle recurring authentication warnings

• Provides a thread-safe, resettable interval gate for outage and fallback logging.

src/Kurrent.Replicator.KurrentDb/Auth/RateLimitedWarning.cs

TokenEndpointClient.csRequest and validate OAuth access tokens +214/-0

Request and validate OAuth access tokens

• Builds secret- or assertion-based requests, validates responses and lifetimes, blocks redirects, and limits provider text exposed in errors and logs.

src/Kurrent.Replicator.KurrentDb/Auth/TokenEndpointClient.cs

TokenFileSource.csLoad externally managed tokens from a file +70/-0

Load externally managed tokens from a file

• Periodically reads tokens, tolerates transient missing or empty files, and immediately reloads after rejection.

src/Kurrent.Replicator.KurrentDb/Auth/TokenFileSource.cs

TokenGate.csWait for token-source recovery +38/-0

Wait for token-source recovery

• Retries unavailable tokens with capped exponential backoff until success or cancellation.

src/Kurrent.Replicator.KurrentDb/Auth/TokenGate.cs

TokenState.csTrack rejection quarantine and probe leases +98/-0

Track rejection quarantine and probe leases

• Uses generations to ignore stale reports, quarantines rejected tokens, and permits one timed probe.

src/Kurrent.Replicator.KurrentDb/Auth/TokenState.cs

Configurator.csConfigure independent reader and sink authentication +57/-10

Configure independent reader and sink authentication

• Validates each side, constructs its token source and auth context, and preserves connection-string behavior by default.

src/Kurrent.Replicator.KurrentDb/Configurator.cs

ConnectionExtensions.csPass credentials to metadata and size reads +6/-6

Pass credentials to metadata and size reads

• Adds per-call credentials and cancellation to stream metadata and size helpers.

src/Kurrent.Replicator.KurrentDb/ConnectionExtensions.cs

EventFilters.csAuthenticate scavenge metadata lookups +19/-3

Authenticate scavenge metadata lookups

• Runs metadata and size reads through the auth context with per-call credentials.

src/Kurrent.Replicator.KurrentDb/EventFilters.cs

GrpcEventReader.csAuthenticate gRPC reads and position checks +79/-49

Authenticate gRPC reads and position checks

• Supplies credentials to reads and position queries, attributes failures to token leases, and enables fail-closed metadata behavior for OAuth.

src/Kurrent.Replicator.KurrentDb/GrpcEventReader.cs

GrpcEventWriter.csAuthenticate and retry gRPC writes +44/-23

Authenticate and retry gRPC writes

• Uses the auth context for appends, deletes, and metadata writes so token failures pause and retry operations.

src/Kurrent.Replicator.KurrentDb/GrpcEventWriter.cs

ConfigRedaction.csMask sensitive configuration output +18/-0

Mask sensitive configuration output

• Hides connection strings and auth values other than the authentication type when displaying configuration.

src/Kurrent.Replicator.Shared/ConfigRedaction.cs

EnvConfigProvider.csRedact environment variables printed at startup +8/-10

Redact environment variables printed at startup

• Masks displayed configuration values while retaining unmasked values for binding.

src/replicator/Settings/EnvConfigProvider.cs

ReplicatorSettings.csAdd authentication settings to cluster configuration +6/-3

Add authentication settings to cluster configuration

• Adds an auth section to the settings shared by reader and sink.

src/replicator/Settings/ReplicatorSettings.cs

Startup.csBind and validate auth for each side +16/-1

Bind and validate auth for each side

• Converts settings independently, rejects OAuth on non-gRPC protocols, and passes both options to the configurator.

src/replicator/Startup.cs

Bug fix (3) +245 / -29
Realtime.csSerialize realtime subscription recovery +161/-11

Serialize realtime subscription recovery

• Replaces one-shot resubscription with an attempt-aware retry loop, attributes drops to leases, and marks cache live only for published subscriptions.

src/Kurrent.Replicator.KurrentDb/Realtime.cs

StreamMetaCache.csInvalidate scavenge metadata after subscription gaps +53/-9

Invalidate scavenge metadata after subscription gaps

• Tracks live state and epochs, clears stale entries on resubscription, and propagates authentication failures for OAuth readers.

src/Kurrent.Replicator.KurrentDb/StreamMetaCache.cs

Replicator.csPrevent shutdown stalls and preserve metrics reporting +31/-9

Prevent shutdown stalls and preserve metrics reporting

• Cancels blocked sink handoffs, avoids waiting for undrainable events, and retries failed source-position reads.

src/Kurrent.Replicator/Replicator.cs

Documentation (9) +5719 / -0
CHANGELOG.mdRecord OAuth support and reliability fixes +4/-0

Record OAuth support and reliability fixes

• Lists authentication and Helm capabilities, startup redaction, subscription recovery, and shutdown fixes.

CHANGELOG.md

authentication.mdxDocument authentication modes and token handling +118/-0

Document authentication modes and token handling

• Explains per-side options, permissions, recovery, security constraints, and provider and token-file examples.

docs/src/content/docs/deployment/authentication.mdx

configuration.mdxReference reader and sink auth settings +2/-0

Reference reader and sink auth settings

• Adds both authentication sections to the configuration table and links to the detailed guide.

docs/src/content/docs/deployment/configuration.mdx

docker.mdxShow Docker OAuth environment configuration +25/-0

Show Docker OAuth environment configuration

• Provides a client-credentials Compose example and explains startup-output masking.

docs/src/content/docs/deployment/docker.mdx

kubernetes.mdxExplain Helm OAuth and workload-identity setup +55/-0

Explain Helm OAuth and workload-identity setup

• Documents chart values, Kubernetes Secret injection, an OAuth sink example, and Azure Workload Identity.

docs/src/content/docs/deployment/kubernetes.mdx

readers.mdxLink gRPC readers to the auth guide +2/-0

Link gRPC readers to the auth guide

• Notes that gRPC readers support OAuth access tokens.

docs/src/content/docs/features/readers.mdx

sinks.mdxLink gRPC sinks to the auth guide +2/-0

Link gRPC sinks to the auth guide

• Notes that gRPC sinks support OAuth access tokens.

docs/src/content/docs/features/sinks.mdx

2026-10-03-oauth-authentication.mdAdd the OAuth implementation plan +5141/-0

Add the OAuth implementation plan

• Records implementation tasks, integration seams, test strategy, constraints, and review focus.

docs/superpowers/plans/2026-10-03-oauth-authentication.md

2026-10-03-oauth-authentication-design.mdSpecify the OAuth architecture and behavior +370/-0

Specify the OAuth architecture and behavior

• Defines supported flows, credential integration, configuration, token lifecycle, failure handling, and security requirements.

docs/superpowers/specs/2026-10-03-oauth-authentication-design.md

Other (29) +2484 / -0
statefulset.yamlExpose pod identity and secret-injection hooks +20/-0

Expose pod identity and secret-injection hooks

• Renders optional pod labels, service account, environment sources, volumes, and mounts.

charts/replicator/templates/statefulset.yaml

values.yamlDefine optional OAuth deployment values +25/-0

Define optional OAuth deployment values

• Adds defaults and examples for per-side authentication, secrets, and workload identity.

charts/replicator/values.yaml

AssemblyInfo.csExpose auth internals to unit tests +3/-0

Expose auth internals to unit tests

• Grants the test assembly access to internal gRPC authentication components.

src/Kurrent.Replicator.KurrentDb/AssemblyInfo.cs

Kurrent.Replicator.KurrentDb.csprojImport auth and logging namespaces +2/-0

Import auth and logging namespaces

• Adds global using directives needed by the gRPC authentication code.

src/Kurrent.Replicator.KurrentDb/Kurrent.Replicator.KurrentDb.csproj

AuthFailureTests.csTest classification of nested auth failures +41/-0

Test classification of nested auth failures

• Covers token errors, permission denials, unrelated exceptions, and exception-chain traversal.

test/Kurrent.Replicator.Tests/Auth/AuthFailureTests.cs

AuthSettingsBindingTests.csTest independent and environment-based auth binding +95/-0

Test independent and environment-based auth binding

• Covers defaults, mixed modes, additional parameters, invalid values, and secret-safe formatting.

test/Kurrent.Replicator.Tests/Auth/AuthSettingsBindingTests.cs

ClientCredentialsTokenSourceTests.csTest token caching and refresh recovery +305/-0

Test token caching and refresh recovery

• Exercises single-flight refresh, fallback, cooldown, short lifetimes, rejection, cancellation, timeouts, and warning limits.

test/Kurrent.Replicator.Tests/Auth/ClientCredentialsTokenSourceTests.cs

ConfigRedactionTests.csTest startup-value masking rules +27/-0

Test startup-value masking rules

• Verifies sensitive values are masked while auth types and unrelated values remain visible.

test/Kurrent.Replicator.Tests/Auth/ConfigRedactionTests.cs

GrpcAuthContextTests.csTest per-call auth retry and reporting +114/-0

Test per-call auth retry and reporting

• Checks lease attribution, token replacement, cancellation, permission-error propagation, and behavior without OAuth.

test/Kurrent.Replicator.Tests/Auth/GrpcAuthContextTests.cs

GrpcAuthOptionsValidatorTests.csTest OAuth configuration validation +116/-0

Test OAuth configuration validation

• Exercises required options, credential conflicts, protocol and transport restrictions, and source-specific validation.

test/Kurrent.Replicator.Tests/Auth/GrpcAuthOptionsValidatorTests.cs

GrpcAuthenticationTests.csTest gRPC credential callback behavior +81/-0

Test gRPC credential callback behavior

• Verifies per-call credentials, fallback authentication, streaming reads, and source selection.

test/Kurrent.Replicator.Tests/Auth/GrpcAuthenticationTests.cs

GrpcConfiguratorTests.csTest per-side gRPC client configuration +50/-0

Test per-side gRPC client configuration

• Checks independent basic and OAuth headers and side-specific configuration errors.

test/Kurrent.Replicator.Tests/Auth/GrpcConfiguratorTests.cs

GrpcEventReaderAuthTests.csTest authenticated reader operations +77/-0

Test authenticated reader operations

• Verifies event-read token attribution and recovery of position queries after rejection.

test/Kurrent.Replicator.Tests/Auth/GrpcEventReaderAuthTests.cs

GrpcEventWriterAuthTests.csTest token-aware write and outage recovery +182/-0

Test token-aware write and outage recovery

• Covers write kinds, outages, cancellation, file rotation, quarantine, and single-call probing.

test/Kurrent.Replicator.Tests/Auth/GrpcEventWriterAuthTests.cs

RealtimeTests.csTest subscription attempt and drop races +246/-0

Test subscription attempt and drop races

• Exercises retries, shared attempts, shutdown, stale callbacks, pending drops, and cache live state.

test/Kurrent.Replicator.Tests/Auth/RealtimeTests.cs

ReplicatorShutdownTests.csTest replication recovery and bounded shutdown +147/-0

Test replication recovery and bounded shutdown

• Checks token outages, shutdown with a full sink channel, and metrics reporting after a position-read failure.

test/Kurrent.Replicator.Tests/Auth/ReplicatorShutdownTests.cs

ScavengedEventsFilterAuthTests.csTest authenticated scavenge filtering +123/-0

Test authenticated scavenge filtering

• Covers credential-bearing metadata reads and behavior when authentication prevents a reliable scavenge decision.

test/Kurrent.Replicator.Tests/Auth/ScavengedEventsFilterAuthTests.cs

StreamMetaCacheTests.csTest cache live state and failure policy +73/-0

Test cache live state and failure policy

• Checks cache invalidation and epoch races, uncached reads during gaps, and OAuth-only fail-closed errors.

test/Kurrent.Replicator.Tests/Auth/StreamMetaCacheTests.cs

ControllableTokenSource.csProvide a controllable token-source fixture +41/-0

Provide a controllable token-source fixture

• Supplies token leases and outcome tracking for authentication tests.

test/Kurrent.Replicator.Tests/Auth/Support/ControllableTokenSource.cs

FakeKurrentDbHandler.csSimulate KurrentDB gRPC requests +87/-0

Simulate KurrentDB gRPC requests

• Provides an HTTP handler for checking authorization headers and server responses without a live cluster.

test/Kurrent.Replicator.Tests/Auth/Support/FakeKurrentDbHandler.cs

LogCapture.csCapture authentication logs in tests +35/-0

Capture authentication logs in tests

• Collects Serilog events for redaction and rate-limiting assertions.

test/Kurrent.Replicator.Tests/Auth/Support/LogCapture.cs

StubTokenEndpoint.csStub token endpoint responses +47/-0

Stub token endpoint responses

• Provides a controllable token HTTP endpoint for acquisition and failure-path tests.

test/Kurrent.Replicator.Tests/Auth/Support/StubTokenEndpoint.cs

TestEvents.csProvide reusable replication test events +23/-0

Provide reusable replication test events

• Builds event fixtures used by reader, writer, and lifecycle tests.

test/Kurrent.Replicator.Tests/Auth/Support/TestEvents.cs

TimeDriver.csAdvance simulated time for retry tests +31/-0

Advance simulated time for retry tests

• Drives fake time until asynchronous operations or conditions complete.

test/Kurrent.Replicator.Tests/Auth/Support/TimeDriver.cs

TokenEndpointClientTests.csTest token protocol and error sanitization +201/-0

Test token protocol and error sanitization

• Covers secret and assertion requests, response validation, lifetimes, redirects, and provider-error redaction.

test/Kurrent.Replicator.Tests/Auth/TokenEndpointClientTests.cs

TokenFileSourceTests.csTest token-file rotation and rejection +104/-0

Test token-file rotation and rejection

• Exercises reload intervals, empty files, immediate rereads, quarantine, and cancellation.

test/Kurrent.Replicator.Tests/Auth/TokenFileSourceTests.cs

TokenGateTests.csTest token wait, backoff, and warning limits +59/-0

Test token wait, backoff, and warning limits

• Verifies recovery, cancellation, capped retry delays, and rate-limited outage logging.

test/Kurrent.Replicator.Tests/Auth/TokenGateTests.cs

TokenStateTests.csTest generation-safe token quarantine +126/-0

Test generation-safe token quarantine

• Covers stale reports, replacement tokens, single-call probing, probe expiry, and renewed quarantine.

test/Kurrent.Replicator.Tests/Auth/TokenStateTests.cs

Kurrent.Replicator.Tests.csprojAdd auth-test support packages +3/-0

Add auth-test support packages

• Adds configuration binding and fake-time dependencies for the new unit tests.

test/Kurrent.Replicator.Tests/Kurrent.Replicator.Tests.csproj

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@qodo-code-review

qodo-code-review Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Provider error text reaches debug logs ✓ Resolved
Description
TokenEndpointClient.ErrorResponse logs the provider's error_description at debug level after
replacing only a short list of known secret values. When a provider includes another sensitive value
in that field, its text is retained in debug logs rather than omitted.
Code

src/Kurrent.Replicator.KurrentDb/Auth/TokenEndpointClient.cs[199]

+        if (description != null && Log.IsDebugEnabled()) Log.Debug("{Side}: token endpoint error_description: {Description}", _side, Redact(description));
Evidence
The response parser takes error_description directly from provider JSON. Its debug log emits that
text after Redact, which replaces only the configured client secret, last assertion, and four
retained access tokens; the existing test confirms provider text appears in debug output.

src/Kurrent.Replicator.KurrentDb/Auth/TokenEndpointClient.cs[172-199]
src/Kurrent.Replicator.KurrentDb/Auth/TokenEndpointClient.cs[204-210]
test/Kurrent.Replicator.Tests/Auth/TokenEndpointClientTests.cs[175-201]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Debug logging emits provider-controlled OAuth error descriptions, while replacement covers only secrets already known to the client.
## Fix Focus Areas
- src/Kurrent.Replicator.KurrentDb/Auth/TokenEndpointClient.cs[172-210]
- test/Kurrent.Replicator.Tests/Auth/TokenEndpointClientTests.cs[175-201]
## Recommended Fix
Do not log `error_description`, including at debug level. Retain the HTTP status and allowlisted error code for diagnosis, and update the logging test accordingly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Old subscriptions can restore stale metadata ✓ Resolved
Description
SubscribeOnce registers the same HandleEvent callback for every attempt without checking which
attempt delivered an event. If an old subscription has a callback in flight when its replacement
becomes live and clears the cache, that callback can refill the cleared entry with pre-gap metadata
used by the scavenge filter.
Code

src/Kurrent.Replicator.KurrentDb/Realtime.cs[104]

+            subscription = await _subscribe(HandleEvent, (reason, ex) => HandleDrop(attempt, reason, ex), auth, ct).ConfigureAwait(false);
Evidence
Drop handling replaces the published attempt, and replacement publication clears the cache. Unlike
drop callbacks, event callbacks carry no attempt identity and still write to that cache; a cleared
entry has no newer version to reject a delayed old metadata update.

src/Kurrent.Replicator.KurrentDb/Realtime.cs[96-104]
src/Kurrent.Replicator.KurrentDb/Realtime.cs[122-128]
src/Kurrent.Replicator.KurrentDb/Realtime.cs[141-164]
src/Kurrent.Replicator.KurrentDb/Realtime.cs[174-187]
src/Kurrent.Replicator.KurrentDb/StreamMetaCache.cs[29-35]
src/Kurrent.Replicator.KurrentDb/StreamMetaCache.cs[72-82]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Callbacks from a dropped subscription can update the cache after a replacement subscription clears and republishes it.
## Fix Focus Areas
- src/Kurrent.Replicator.KurrentDb/Realtime.cs[96-104]
- src/Kurrent.Replicator.KurrentDb/Realtime.cs[122-128]
- src/Kurrent.Replicator.KurrentDb/Realtime.cs[174-187]
## Recommended Fix
Capture the subscription attempt in the event callback and ignore cache updates unless it is the current, non-dropped attempt. Add a test delivering an old callback after replacement publication.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. OAuth metadata reads can delay shutdown ✓ Resolved
Description
GrpcAuthContext.Run links the caller token to application shutdown for token acquisition, but
passes the original token to the gRPC call. The scavenge filter supplies CancellationToken.None
for metadata and stream-size reads, so an in-flight lookup is not cancelled when shutdown begins.
Code

src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthContext.cs[58]

+                var result = await call(auth, ct).ConfigureAwait(false);
Evidence
The linked token includes Shutdown, but the operation receives ct; both production filter
lookups invoke Run with an uncancellable caller token and forward the resulting token into gRPC.

src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthContext.cs[48-58]
src/Kurrent.Replicator.KurrentDb/EventFilters.cs[12-15]
src/Kurrent.Replicator.KurrentDb/EventFilters.cs[20-34]
src/Kurrent.Replicator.KurrentDb/ConnectionExtensions.cs[4-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
OAuth-wrapped gRPC calls receive the caller token instead of the token linked to application shutdown. Scavenge lookups use `CancellationToken.None`, leaving active calls uncancelled during shutdown.
## Fix Focus Areas
- src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthContext.cs[48-58]
- src/Kurrent.Replicator.KurrentDb/EventFilters.cs[20-34]
## Recommended Fix
Pass the linked token to the gRPC callback, and test shutdown while a scavenge lookup is in flight.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (1)
4. Mongo checkpoint password still printed at startup ✓ Resolved
Description
ConfigRedaction.Display masks a value only when the last key segment is ConnectionString or the
key sits under an Auth segment, so REPLICATOR_CHECKPOINT_PATH (and the seeder Path) is still
printed in full. With checkpoint.type: mongo, that key holds a MongoDB connection string, so any
user:password@ in it ends up in the container logs, even though the changelog and the Docker docs
say connection strings are masked.
Code

src/Kurrent.Replicator.Shared/ConfigRedaction.cs[R5-8]

+    public static string? Display(string configKey, string? value) {
+        var segments = configKey.Split(':');
+
+        if (segments[^1].Equals("ConnectionString", StringComparison.OrdinalIgnoreCase)) return "***";
Evidence
EnvConfigProvider now prints every REPLICATOR_* variable through ConfigRedaction.Display. Display
masks only keys whose last segment is ConnectionString or that contain an Auth segment. The
Mongo checkpoint store gets settings.Path as its connection string
(MongoClientSettings.FromConnectionString(connectionString)), and that comes from the
Checkpoint:Path key, which matches neither rule and is printed as-is.

src/Kurrent.Replicator.Shared/ConfigRedaction.cs[5-17]
src/replicator/Settings/EnvConfigProvider.cs[16-19]
src/replicator/Startup.cs[109-118]
src/Kurrent.Replicator.Mongo/MongoCheckpointStore.cs[17-18]
src/replicator/Settings/ReplicatorSettings.cs[21-28]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
ConfigRedaction.Display masks only `*:ConnectionString` and `*:Auth:*` keys. The Mongo checkpoint store takes its connection string from `Replicator:Checkpoint:Path`, which is printed unmasked when REPLICATOR_CHECKPOINT_PATH is set, so any password in it reaches the logs.

## Fix Focus Areas
- src/Kurrent.Replicator.Shared/ConfigRedaction.cs[5-17]

## Recommended Fix
Also mask any value that looks like a URI with userinfo (for example `Uri.TryCreate(value, UriKind.Absolute, out var u) && !string.IsNullOrEmpty(u.UserInfo)`, or values starting with `mongodb://` or `mongodb+srv://`). Alternatively, mask `Checkpoint:Path` and `Checkpoint:Seeder:Path` explicitly. Add a test in ConfigRedactionTests.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: 🧠 Deep: This security-sensitive OAuth implementation spans many independent authentication, token lifecycle, gRPC, configuration, concurrency/recovery, and deployment paths, creating a high density of subtle defects that merits redundant review.

Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Kurrent.Replicator.KurrentDb/Auth/GrpcAuthContext.cs
Comment thread src/Kurrent.Replicator.KurrentDb/Realtime.cs Outdated
Comment thread src/Kurrent.Replicator.KurrentDb/Auth/TokenEndpointClient.cs Outdated
Comment thread src/Kurrent.Replicator.Shared/ConfigRedaction.cs
realtonyyoung and others added 7 commits October 3, 2026 11:49
ScavengedEventsFilter passes the auth context's Shutdown token as the caller
token to auth.Run, so an in-flight metadata or stream-size gRPC lookup is
cancelled at shutdown instead of only the credential wait. GrpcAuthContext.Run
is unchanged so writers keep graceful shutdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts (Qodo #2)

The onEvent callback is now bound to its Attempt, and HandleEvent updates the
StreamMetaCache only while that attempt is current (pending or published and
not dropped). The check and the update happen under the Realtime lock that also
guards publish + MarkLive, so a superseded attempt's in-flight event cannot
refill the cache with pre-gap metadata after the replacement went live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Provider free text could carry sensitive data beyond the secrets we know to
redact, so the Debug log of error_description is removed along with the now
dead redaction code and the retained access-token list. The test now asserts
error_description text never appears in logs at any level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConfigRedaction.Display now also masks, under any key, a value containing a
URI with user info (scheme://user[:pass]@, matched by pattern so mongodb+srv
and multi-host Mongo strings are covered) or DefaultUserCredentials=. A Mongo
checkpoint path with credentials is no longer printed at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GrpcAuthContext.Run and Realtime.HandleDrop logged e.Message / the raw
drop exception on a token failure. A gRPC server can put arbitrary text
(even an echoed Authorization header) into an Unauthenticated status
detail, so this could write a token into the logs. Add
AuthFailure.Describe to produce a fixed, safe description instead:
OAuthTokenException's own (already-sanitized) message, a fixed string
for Unauthenticated/NotAuthenticatedException, or the exception type
and gRPC status code otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HandleDrop's non-token branch still logged the raw drop exception, and
SubscribeLoop's retry warning logged the exception object directly —
both paths could surface a server-supplied status detail (e.g. an
echoed Authorization header) in the logs. Route every drop and
subscribe-failure log line through AuthFailure.Describe instead, for
all exceptions, not only token failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…npm 10

pnpm 10 skips dependency build scripts unless allowed, so sharp was
missing and the Astro image step failed (Cloudflare Pages build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
realtonyyoung and others added 9 commits October 6, 2026 10:24
A brief sink outage (e.g. a KurrentDB container restart) used up the sink
pipe's ~0.5 s GreenPipes retry, faulted the writer and stalled replication
until the process restarted.

GrpcEventWriter now retries append, delete and set-metadata on transient
failures (gRPC Unavailable, DeadlineExceeded, ResourceExhausted, Aborted,
NotLeaderException, HttpRequestException, IOException, SocketException
anywhere in the exception chain) with TokenGate backoff (1 s up to 30 s) on
the auth context's TimeProvider. The wait ends on shutdown or the caller's
token; the gRPC call itself still only gets the caller's token. Warnings are
rate-limited and log only the exception type and status code; one Info line
is logged on recovery. Writes use StreamState.Any and fixed event ids, so a
retry after an ambiguous failure is safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g (DEV-76)

If the writer shovel ended while the replicator was not stopping, the reader
kept re-reading the same events every cycle and nothing was ever written.

Replicate now stops the reader as soon as the writer ends unexpectedly and,
unless the stopping token fires (a writer cancelled by ApplicationStopping
gets a short grace period), throws ReplicatorFailedException after flushing
the checkpoint. With RestartOnFailure, ReplicatorService rethrows it so the
host stops, and sets a non-zero exit code that Program now returns, so a
container orchestrator restarts the process. Without RestartOnFailure the
service logs and keeps the process and HTTP API up with replication stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compose/oauth runs idsrv4 and three KurrentDB 26 nodes (basic, OAuth x2)
with a generated test CA, plus scenario configs and helper scripts to run
Replicator from source against them. License key goes in a git-ignored .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dedupe (Codex review, DEV-76)

SetStreamMetadataAsync mints a new event id on every call, so retrying a
metadata write whose first attempt landed but whose response was lost
appended a second $metadata event. Metadata is now appended as a $metadata
event to $$<stream> with the proposed event's stable id (StreamState.Any,
per-call credentials, LogPosition returned as before), and KurrentDB
deduplicates the repeat. The body is produced by the same converter the
readers use to parse $metadata events.

Also stops writing "$tb": 0 when the source metadata has no truncate-before
(ValueOrNull returned default(StreamPosition) rather than null).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Codex review, DEV-76)

Any HttpRequestException or IOException in the chain counted as transient,
including a TLS certificate or hostname validation failure (an
HttpRequestException wrapping AuthenticationException). With a wrong
tlsCaFile or certificate the writer retried forever and the host-failure
path never ran. AuthenticationException, NotSupportedException,
UriFormatException and HTTP/2 version-negotiation failures anywhere in the
chain now make the failure non-transient; connection refused/reset,
timeouts and DNS failures stay transient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…CI flake)

When the writer died, Replicate threw while its metrics reporter kept running
on the host's stopping token, logging "Reporting stopped" only once the host
(or test) cancelled it. In tests that log landed in TUnit's per-test output
after the test ended, racing TUnit's unlocked StringBuilder.ToString() and
failing Writer_failure_ends_replication_with_an_error_instead_of_looping with
ArgumentOutOfRangeException (chunkLength). Reproduced at ~1-6 per 1200 runs;
0 per 1200 with this fix.

The reporter now runs on its own token linked to the stopping token and is
cancelled and awaited before Replicate returns or throws (also if writer start
or checkpoint seeding fails). The test sink additionally serialises writes
(TUnit's lazy OutputWriter can hand two threads different locks over one
builder) and never lets a sink failure reach the logging code. New test checks
the reporter is stopped by the time a failed Replicate returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hutdown (Codex review)

- The metrics reporter is now cancelled and awaited in a finally around
  everything after it starts, so a failing final checkpoint flush (file/Mongo)
  no longer leaves it running after Replicate throws.
- Stopping the reporter waits at most 5 s after cancellation, then logs and
  moves on, so a position query that ignores its token cannot hang shutdown or
  hide a writer failure (ReplicatorFailedException).
- TcpEventReader.GetLastPosition honours its token (WaitAsync on the TCP call,
  which takes none).
- Tests: the background-work test uses an explicit start/completion handshake
  (reading starts only once the position query is in flight; the query must have
  finished when Replicate's task completes) instead of racing Task.Run; new
  tests for a throwing checkpoint flush and a token-ignoring position query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…V-1903)

With scavenge on and a KurrentDB 26 source, the prepare pipe failed until it gave
up on the $metadata event of $connectors-mngt/state-projection: the stream has a
$maxCount but no events, so the scavenge filter's stream-size read threw
StreamNotFoundException (or would index an empty result).

- GetStreamSize returns StreamSize.Empty (last event number -1, as the TCP
  reader already reports) when the read is not found or returns no events, so
  nothing in such a stream is over its max count. Not-found is detected on
  enumeration (StreamNotFoundException); ReadState is not awaited because in
  EventStore.Client 23.3.8 it never completes when the server sends no messages.
  Every other failure, auth failures included, still propagates (fail-closed).
- GetStreamMeta no longer throws for a stream without metadata (no metastream
  revision), which logged a warning for every such event with scavenge on.
- System-stream metadata is not special-cased: EmptyDataFilter already turns
  every metadata/deletion event into an ignored event before the scavenge filter,
  so none reaches the sink (no $$$ writes); the filter only had to stop failing.
- OAuth test bed scenarios now run with scavenge on; the known-issue notes are gone.

Verified live (scenario 1, basic -> OAuth, scavenge on): full replay from the
start with no StreamNotFound or warnings, and 50 new events reached the sink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iew)

The token-ignoring position-query test released the fake query and returned at
once, so the abandoned reporter could log "Reporting stopped" after TUnit began
reading the test's output. Replicator now raises an internal ReporterExited
event (test seam, after the reporter's last log line); the test waits for it,
bounded, for its own reader before finishing. Assertions unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@realtonyyoung
realtonyyoung merged commit c18140b into master Oct 6, 2026
5 checks passed
@realtonyyoung realtonyyoung mentioned this pull request Oct 6, 2026
@linear-code

linear-code Bot commented Oct 6, 2026

Copy link
Copy Markdown

DEV-1903

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