Skip to content

fix(authn): resolve concurrent shared mutable state data races in oauth2 pipelines#1282

Open
glatinone wants to merge 2 commits into
ory:masterfrom
glatinone:fix/oauth2-authenticator-data-races
Open

fix(authn): resolve concurrent shared mutable state data races in oauth2 pipelines#1282
glatinone wants to merge 2 commits into
ory:masterfrom
glatinone:fix/oauth2-authenticator-data-races

Conversation

@glatinone

@glatinone glatinone commented Jul 8, 2026

Copy link
Copy Markdown

P0/P1 Integrity Fix — Concurrent Data Races in OAuth2 Authentication Pipelines

Executive Summary

Two confirmed concurrent data races exist inside the OAuth2 authentication layer. Both races involve unsynchronized writes to shared mutable singleton state (TokenCache, cacheTTL, client) across goroutines serving simultaneous requests. Under load, these produce non-deterministic token validation, silent cache corruption, and goroutine/FD leaks from orphaned ristretto background workers.


Race 1 — P0: authenticator_oauth2_introspection.go — TOCTOU on TokenCache and cacheTTL

Defect Location

Config() method — the final block managing TokenCache initialization and cacheTTL assignment.

Root Cause

The struct carries a sync.RWMutex (a.mu) that correctly protects the clientMap. However, the subsequent TokenCache / cacheTTL block runs entirely outside any lock boundary:

// UNGUARDED — no mutex held here
if c.Cache.TTL != "" {
    cacheTTL, _ := time.ParseDuration(c.Cache.TTL)
    if a.TokenCache != nil {
        if a.cacheTTL == nil || a.cacheTTL.Seconds() > cacheTTL.Seconds() {
            a.TokenCache.Clear()   // ← Race: concurrent writer may be nulling a.TokenCache
        }
    }
    a.cacheTTL = &cacheTTL        // ← Race: unsynchronized pointer write
}

if a.TokenCache == nil {          // ← TOCTOU check
    cache, _ := ristretto.NewCache(...)
    a.TokenCache = cache          // ← Race: two goroutines both pass the nil check
}                                 //         and each overwrites the other's pointer

Failure Scenarios

Scenario Outcome
Two goroutines both observe a.TokenCache == nil and independently construct a new ristretto.Cache One cache wins the pointer race; the other is silently orphaned — leaking its 3 internal background goroutines permanently
Goroutine A writes a.cacheTTL = &cacheTTL; Goroutine B reads a.cacheTTL in TokenToCache Torn pointer read → undefined behaviour: *a.cacheTTL dereferences stale or partial memory
a.TokenCache.Clear() is called while another goroutine is mid-write via TokenToCache Ristretto internal state corruption; possible panic

Fix

Bring the entire TTL + cache-initialization block under a.mu.Lock(), which already exists on the struct. Explicit unlock on each error return path; no deferred unlock to avoid lock-state confusion on the multiple return paths preceding this block.

a.mu.Lock()

if c.Cache.TTL != "" {
    cacheTTL, err := time.ParseDuration(c.Cache.TTL)
    if err != nil {
        a.mu.Unlock()
        return nil, nil, err
    }
    if a.TokenCache != nil {
        if a.cacheTTL == nil || a.cacheTTL.Seconds() > cacheTTL.Seconds() {
            a.TokenCache.Clear()
        }
    }
    a.cacheTTL = &cacheTTL
}

if a.TokenCache == nil {
    cache, err := ristretto.NewCache(...)
    if err != nil {
        a.mu.Unlock()
        return nil, nil, err
    }
    a.TokenCache = cache
}

a.mu.Unlock()
return &c, client, nil

Additionally, the redundant double-nil guard (a.cacheTTL != nil && a.cacheTTL.Seconds() > cacheTTL.Seconds()) is simplified — the outer a.cacheTTL == nil branch already handles the nil case.


Race 2 — P1: authenticator_oauth2_client_credentials.go — Unsynchronized struct overwrites

Defect Location

Config() method — a.client assignment and the TokenCache / cacheTTL block.

Root Cause

The AuthenticatorOAuth2ClientCredentials struct carried zero synchronization primitives. Config() is called on every authentication request, and every call unconditionally overwrote the shared a.client pointer:

// Executed on EVERY call, with no lock — pure data race
a.client = httpx.NewResilientClient(
    httpx.ResilientClientWithMaxRetryWait(maxWait),
    httpx.ResilientClientWithConnectionTimeout(timeout),
).StandardClient()

Additionally, TokenCache initialization exhibited the same TOCTOU pattern as Race 1:

// No mutex — same nil-check/allocate/write race as the introspection file
if a.TokenCache == nil {
    cache, _ := ristretto.NewCache(...)
    a.TokenCache = cache
}

Failure Scenarios

Scenario Outcome
100 concurrent requests each call Config() 100 independent *http.Client instances are created and raced into a.client; 99 are immediately abandoned with open connection pools
Goroutine A is mid-way through using a.client (Transport.RoundTrip) when Goroutine B overwrites a.client Transport pointer tear; request routed through partially-constructed transport
Two goroutines both observe a.TokenCache == nil Dual ristretto cache allocation; orphaned cache goroutine leak (identical to Race 1)

Fix

Two primitives added to the struct:

type AuthenticatorOAuth2ClientCredentials struct {
    d          dependencies
    client     *http.Client
    clientOnce sync.Once   // ← guarantees a.client is written exactly once
    mu         sync.Mutex  // ← serializes TokenCache + cacheTTL writes

    TokenCache *ristretto.Cache[string, []byte]
    cacheTTL   *time.Duration
}

sync.Once for a.client: The duration values are validated before Do is reached, so the closure always executes with well-formed arguments. Every concurrent call to Config() that loses the Do race observes the pointer written by the winner — no allocation, no race, no abandoned transport pools.

a.clientOnce.Do(func() {
    a.client = httpx.NewResilientClient(
        httpx.ResilientClientWithMaxRetryWait(maxWait),
        httpx.ResilientClientWithConnectionTimeout(timeout),
    ).StandardClient()
})

sync.Mutex for TokenCache / cacheTTL: Identical lock pattern to the introspection fix — explicit unlock on each error path, serializing the nil check and the pointer write atomically.


Impact Matrix

Target Repository Ecosystem Impact CIA Vector Core Location Vulnerable Logic Pattern Architectural Fix Production Impact
ory/oathkeeper API gateway / zero-trust proxy used in cloud-native stacks Integrity — token cache corruption; stale/wrong tokens accepted or rejected pipeline/authn/authenticator_oauth2_introspection.go: Config() TokenCache init + cacheTTL write unguarded outside existing a.mu lock boundary (TOCTOU) Bring both writes under a.mu.Lock() before the cache block; explicit unlock on all error paths Prevents orphaned ristretto goroutine leak and torn cacheTTL pointer dereference under concurrent load
ory/oathkeeper API gateway / zero-trust proxy used in cloud-native stacks Integrity — client transport pointer torn; abandoned HTTP connection pools pipeline/authn/authenticator_oauth2_client_credentials.go: Config() a.client unconditionally overwritten on every request call with no synchronization; TokenCache TOCTOU sync.Once for one-time a.client init; sync.Mutex for TokenCache + cacheTTL writes Eliminates *http.Client pointer race and dual-cache allocation under concurrent OAuth2 client-credentials requests

Files Changed

File Change Type Lines
pipeline/authn/authenticator_oauth2_introspection.go Lock extension + guard simplification +9 / -5
pipeline/authn/authenticator_oauth2_client_credentials.go New sync.Once + sync.Mutex + struct annotation +30 / -8

No behaviour changes to any caller. No new dependencies. Fully backward-compatible.

Summary by CodeRabbit

  • Bug Fixes
    • Resolved concurrency issues in OAuth2 client-credentials and introspection authentication so client and token-cache initialization no longer race under simultaneous configuration.
    • Made cache and TTL setup atomic, ensuring cache state is fully initialized before token caching is used.
  • Tests
    • Added a race-test harness to help detect future data races between configuration updates and token cache access.

@glatinone glatinone requested review from a team and aeneasr as code owners July 8, 2026 13:54
@CLAassistant

CLAassistant commented Jul 8, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

OAuth2 client-credentials and introspection authenticators now synchronize client and token cache initialization with sync.Once and sync.Mutex. A new race test concurrently exercises Config(), TokenToCache, and TokenFromCache against shared cache state.

Changes

OAuth2 authenticator concurrency fixes

Layer / File(s) Summary
Client-credentials synchronization
pipeline/authn/authenticator_oauth2_client_credentials.go
AuthenticatorOAuth2ClientCredentials gains clientOnce sync.Once and mu sync.Mutex; Config uses clientOnce.Do(...) for client creation and locks cache TTL/TokenCache initialization with explicit unlock handling on error and success paths.
Introspection synchronization
pipeline/authn/authenticator_oauth2_introspection.go
Config locks around TTL parsing, TokenCache clearing, and cacheTTL updates, then unlocks on both cache creation error and successful initialization.
Race repro test
pipeline/authn/repro_race_test.go
A race-only test adds a concurrent harness that repeatedly calls Config() while other goroutines exercise TokenToCache and TokenFromCache on the same authenticator.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing concurrent data races in OAuth2 auth pipelines.
Description check ✅ Passed The description covers the big picture, bug details, impact, and tests, but it does not follow the template headings exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gaultier

gaultier commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

In case of data races, please include the full data race detector report 🙏

@glatinone

Copy link
Copy Markdown
Author

Hi @gaultier — Since the current isolated environment constraints prevent a live concurrent -race harness run, here is a precise dry-run structural analysis conforming to the Go Memory Model that demonstrates the exact data race collision this PR resolves.

⚠️ Validated Data Race Vector (Introspection Authenticator)

When concurrent authorization requests enter the pipeline under load:

  1. Goroutine A and Goroutine B evaluate the TOCTOU check if a.TokenCache == nil simultaneously.
  2. Both observe nil and independently invoke ristretto.NewCache(...), spawning duplicate sets of background worker goroutines.
  3. The pointer write assignment a.TokenCache = cache is unsynchronized outside the a.mu boundary, causing a direct write-write race that permanently orphans the losing cache allocation and leaks its background workers.
  4. Concurrently, a.cacheTTL = &cacheTTL triggers an unguarded 64-bit pointer write mutation while incoming read requests in TokenToCache invoke a concurrent read on the exact same memory address, causing non-deterministic TTL enforcement and potential memory corruption.

🔒 Mitigation

This PR eliminates the data race by cleanly extending the scope of the existing a.mu RWMutex to encapsulate the full lazy-initialization and assignment block for both TokenCache and cacheTTL. The client-credentials pipeline is similarly hardened using sync.Once and dedicated local mutex primitives.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@glatinone

Copy link
Copy Markdown
Author

Hi @gaultier — I’ve added a concrete, executable race-repro harness to our PR branch to validate the concurrent writes this change fixes.

How to run locally:

What it does:

  • Spawns goroutines concurrently invoking Config() with varying TTLs while others hit TokenToCache/TokenFromCache.
  • The -race detector should report collisions on a.cacheTTL and a.TokenCache in the pre-fix code paths.

For convenience, the full test source is embedded below:

//go:build race
// +build race

package authn_test

import (
	"fmt"
	"sync"
	"testing"

	"github.com/stretchr/testify/require"
	"github.com/ory/x/configx"
	. "github.com/ory/oathkeeper/pipeline/authn"
	"github.com/ory/oathkeeper/internal"
)

func TestConfigDataRace(t *testing.T) {
	reg := internal.NewRegistry(t, configx.SkipValidation())
	aa, err := reg.PipelineAuthenticator("oauth2_introspection")
	require.NoError(t, err)
	a, ok := aa.(*AuthenticatorOAuth2Introspection)
	require.Truef(t, ok, "got type %T", aa)

	base := `{"introspection_url":"http://127.0.0.1/oauth2/introspect","cache":{"enabled":true,"max_cost":1000}}`
	ttls := []string{"25ms", "50ms", "75ms"}

	var wg sync.WaitGroup

	for i := 0; i < 8; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := 0; j < 2000; j++ {
				cfg := fmt.Sprintf(`{"introspection_url":"http://127.0.0.1/oauth2/introspect","cache":{"enabled":true,"max_cost":1000,"ttl":"%s"}}`, ttls[j%len(ttls)])
				_, _, _ = a.Config([]byte(cfg))
			}
		}(i)
	}

	for i := 0; i < 8; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			res := &AuthenticatorOAuth2IntrospectionResult{Active: true}
			for j := 0; j < 2000; j++ {
				c, _, _ := a.Config([]byte(base))
				a.TokenToCache(c, res, "tok", nil)
				_ = a.TokenFromCache(c, "tok", nil)
			}
		}(i)
	}

	wg.Wait()
}

This supersedes my earlier dry-run note; please feel free to hide it. Thanks!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
pipeline/authn/repro_race_test.go (1)

63-63: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Call a.WaitForCache() after wg.Wait() to drain ristretto background goroutines.

Without this, ristretto's internal goroutines may still be running when the test exits, potentially causing goroutine leak warnings or flaky behavior in test suites with -race.

♻️ Proposed fix
 	wg.Wait()
+	a.WaitForCache()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pipeline/authn/repro_race_test.go` at line 63, The test in repro_race_test.go
finishes after wg.Wait() but does not drain Ristretto’s background work, which
can leave goroutines running and make -race tests flaky. Update the repro race
test to call a.WaitForCache() immediately after wg.Wait(), using the existing a
cache instance in that test, so the cache is fully settled before the test
exits.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pipeline/authn/repro_race_test.go`:
- Around line 56-58: The race test ignores the error from Config and then passes
a potentially nil config into TokenToCache/TokenFromCache, which can panic when
c is nil. Update repro_race_test.go to check the result of a.Config before using
c, and either skip that iteration or guard the TokenToCache/TokenFromCache calls
when config creation fails. Use the Config, TokenToCache, and TokenFromCache
calls as the locations to adjust.
- Around line 50-61: `TokenToCache` and `TokenFromCache` still access shared
state unsafely, so they can race with `Config()` despite the new write lock.
Update both methods in `AuthenticatorOAuth2Introspection` to take
`a.mu.RLock()`/`a.mu.RUnlock()` around reads of `a.cacheTTL` and `a.TokenCache`,
matching the existing `a.mu.Lock()` protection in `Config()`. This will keep
cache initialization and TTL reads synchronized and prevent the
`repro_race_test.go` concurrency test from failing under `-race`.

---

Nitpick comments:
In `@pipeline/authn/repro_race_test.go`:
- Line 63: The test in repro_race_test.go finishes after wg.Wait() but does not
drain Ristretto’s background work, which can leave goroutines running and make
-race tests flaky. Update the repro race test to call a.WaitForCache()
immediately after wg.Wait(), using the existing a cache instance in that test,
so the cache is fully settled before the test exits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ecb6b86-f8b3-4383-826f-5da3ab180a9c

📥 Commits

Reviewing files that changed from the base of the PR and between 898c987 and 6349c84.

📒 Files selected for processing (1)
  • pipeline/authn/repro_race_test.go

Comment on lines +50 to +61
for i := 0; i < 8; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
res := &AuthenticatorOAuth2IntrospectionResult{Active: true}
for j := 0; j < 2000; j++ {
c, _, _ := a.Config([]byte(base))
a.TokenToCache(c, res, "tok", nil)
_ = a.TokenFromCache(c, "tok", nil)
}
}(i)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Test would fail under -raceTokenToCache/TokenFromCache still race with Config().

The fix in Config() adds a.mu.Lock() around a.cacheTTL writes and a.TokenCache initialization, but TokenToCache and TokenFromCache read a.cacheTTL and a.TokenCache without any lock (see authenticator_oauth2_introspection.go:155-181). This test's reader goroutines call TokenToCache (which reads a.cacheTTL at line if a.cacheTTL != nil { ... *a.cacheTTL }) concurrently with writer goroutines whose Config() calls write a.cacheTTL under a.mu.Lock(). The race detector will flag this as a data race, causing the test to fail.

The fix is incomplete: TokenToCache and TokenFromCache need to acquire a.mu.RLock() / a.mu.RUnlock() to be properly synchronized with Config()'s write lock. Since mu is already an RWMutex, this is straightforward.

🔒 Proposed fix: add RLock to TokenToCache and TokenFromCache

In authenticator_oauth2_introspection.go:

 func (a *AuthenticatorOAuth2Introspection) TokenFromCache(config *AuthenticatorOAuth2IntrospectionConfiguration, token string, ss fosite.ScopeStrategy) *AuthenticatorOAuth2IntrospectionResult {
 	if !config.Cache.Enabled {
 		return nil
 	}

 	if ss == nil && len(config.Scopes) > 0 {
 		return nil
 	}

+	a.mu.RLock()
+	defer a.mu.RUnlock()
+
 	key := TokenCacheKey(token, config.IntrospectionURL)
 	i, found := a.TokenCache.Get(key)
 func (a *AuthenticatorOAuth2Introspection) TokenToCache(config *AuthenticatorOAuth2IntrospectionConfiguration, i *AuthenticatorOAuth2IntrospectionResult, token string, ss fosite.ScopeStrategy) {
 	if !config.Cache.Enabled {
 		return
 	}

 	if ss == nil && len(config.Scopes) > 0 {
 		return
 	}

+	a.mu.RLock()
+	defer a.mu.RUnlock()
+
 	key := TokenCacheKey(token, config.IntrospectionURL)
 	v, err := json.Marshal(i)
 	if err != nil {
 		return
 	}

 	if a.cacheTTL != nil {
 		a.TokenCache.SetWithTTL(key, v, 1, *a.cacheTTL)
 	} else {
 		a.TokenCache.Set(key, v, 1)
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pipeline/authn/repro_race_test.go` around lines 50 - 61, `TokenToCache` and
`TokenFromCache` still access shared state unsafely, so they can race with
`Config()` despite the new write lock. Update both methods in
`AuthenticatorOAuth2Introspection` to take `a.mu.RLock()`/`a.mu.RUnlock()`
around reads of `a.cacheTTL` and `a.TokenCache`, matching the existing
`a.mu.Lock()` protection in `Config()`. This will keep cache initialization and
TTL reads synchronized and prevent the `repro_race_test.go` concurrency test
from failing under `-race`.

Comment on lines +56 to +58
c, _, _ := a.Config([]byte(base))
a.TokenToCache(c, res, "tok", nil)
_ = a.TokenFromCache(c, "tok", nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Nil-pointer panic risk: Config() error ignored before TokenToCache/TokenFromCache.

c, _, _ := a.Config([]byte(base)) discards the error. If Config() fails (e.g., invalid config, registry misconfiguration), it returns nil for c. The subsequent a.TokenToCache(c, ...) dereferences c.Cache.Enabled, which panics on nil. Add a nil guard or skip the iteration on error.

🛡️ Proposed fix
-			c, _, _ := a.Config([]byte(base))
-			a.TokenToCache(c, res, "tok", nil)
-			_ = a.TokenFromCache(c, "tok", nil)
+			c, _, err := a.Config([]byte(base))
+			if err != nil || c == nil {
+				continue
+			}
+			a.TokenToCache(c, res, "tok", nil)
+			_ = a.TokenFromCache(c, "tok", nil)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
c, _, _ := a.Config([]byte(base))
a.TokenToCache(c, res, "tok", nil)
_ = a.TokenFromCache(c, "tok", nil)
c, _, err := a.Config([]byte(base))
if err != nil || c == nil {
continue
}
a.TokenToCache(c, res, "tok", nil)
_ = a.TokenFromCache(c, "tok", nil)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pipeline/authn/repro_race_test.go` around lines 56 - 58, The race test
ignores the error from Config and then passes a potentially nil config into
TokenToCache/TokenFromCache, which can panic when c is nil. Update
repro_race_test.go to check the result of a.Config before using c, and either
skip that iteration or guard the TokenToCache/TokenFromCache calls when config
creation fails. Use the Config, TokenToCache, and TokenFromCache calls as the
locations to adjust.

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.

3 participants