Skip to content

[BUG/Question] Race Condition in OpenID Configuration Refresh #233

@davidhuser

Description

@davidhuser

Race Condition in OpenID Configuration Refresh

Describe the bug

There's a potential race condition in the OpenID configuration refresh mechanism. When multiple concurrent requests try to refresh the configuration simultaneously, all requests trigger separate calls to the configuration and keys endpoints, even though only one refresh is necessary.

To Reproduce

  1. Create a scenario with expired or uninitialized OpenID configuration
  2. Make multiple concurrent requests that would trigger a configuration refresh
  3. Observe multiple identical calls to the configuration and keys endpoints

Example test case:

async def test_concurrent_refresh_requests():
    """Test that concurrent refreshes are handled correctly"""
    with respx.mock(assert_all_called=True) as mock:

        async def slow_config_response(*args, **kwargs):
            await asyncio.sleep(0.2)
            return httpx.Response(200, json=openid_configuration())

        async def slow_keys_response(*args, **kwargs):
            await asyncio.sleep(0.2)
            return httpx.Response(200, json=build_openid_keys())

        config_route = mock.get(openid_config_url()).mock(side_effect=slow_config_response)
        keys_route = mock.get(keys_url()).mock(side_effect=slow_keys_response)

        azure_scheme.openid_config._config_timestamp = None

        tasks = [azure_scheme.openid_config.load_config() for _ in range(5)]
        await asyncio.gather(*tasks)

        assert len(config_route.calls) == 1, "Config endpoint called multiple times"
        assert len(keys_route.calls) == 1, "Keys endpoint called multiple times"
        assert len(azure_scheme.openid_config.signing_keys) == 2

Leading to:

FAILED tests/test_provider_config.py::test_concurrent_refresh_requests - AssertionError: Config endpoint called multiple times

Your configuration

n/a

Root cause analysis

The current implementation doesn't protect against concurrent refresh requests. When the configuration is expired or uninitialized and multiple requests arrive simultaneously, each request independently determines a refresh is needed and initiates its own HTTP calls.

Suggested fix

Use an asyncio.Lock() to ensure only one refresh operation happens at a time:

import asyncio

class OpenIdConfig:
    def __init__(
        self,
        ...
    ) -> None:
       ...
        self._lock = asyncio.Lock() # add this to the global config object

    async def load_config(self) -> None:
        """
        Loads config from the Intility openid-config endpoint if it's over 24 hours old (or don't exist)
        """
        async with self._lock:  # acquire lock and refresh
            # do the refresh

This implementation would lead the above test to pass.

Now I'm wondering if such a race condition is actually a problem or is it just theoretical? I must admit I am no expert in asyncio but stumbled upon this issue when testing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions