From 15dc4b6dadca5f95b836907f4394dbfc8d40b06c Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Wed, 30 Sep 2026 22:54:34 +0000 Subject: [PATCH] Add single/multi identity strategy to OIDC providers OIDCProviders and OIDCMixin accept strategy='single-identity' (default) or 'multi-identity'. Single-identity evicts other providers' identities and tokens after a successful login so the session holds exactly one user, matching Spring Security's last-login-wins behavior. Ephemeral flow keys are preserved so in-flight logins can still complete. --- packages/kaya-oidc/README.md | 37 +++++- packages/kaya-oidc/src/kaya/oidc/__init__.py | 3 +- packages/kaya-oidc/src/kaya/oidc/_mixin.py | 49 ++++++- .../kaya-oidc/src/kaya/oidc/_providers.py | 28 +++- packages/kaya-oidc/tests/test_oidc.py | 121 +++++++++++++++++- 5 files changed, 224 insertions(+), 14 deletions(-) diff --git a/packages/kaya-oidc/README.md b/packages/kaya-oidc/README.md index 4ae6c66..a5256e0 100644 --- a/packages/kaya-oidc/README.md +++ b/packages/kaya-oidc/README.md @@ -86,19 +86,41 @@ async def who(ctx: HttpContext): await ctx.send_str(200, ', '.join(users)) ``` -Each provider's session state is namespaced as `oidc:{provider}:...`, so a user -can be logged into several providers at once. Logging out of one provider only -removes that provider's state and keeps the shared session alive while another -provider is still logged in. Make sure each config's `redirect_uri` points at -that provider's derived callback path. +Each provider's session state is namespaced as `oidc:{provider}:...`. Make sure +each config's `redirect_uri` points at that provider's derived callback path. + +### Identity strategy + +`strategy` controls how many identities may coexist in the shared session: + +- `'single-identity'` (default): completing a login evicts every other + provider's identity and tokens, so the session holds exactly one user — last + login wins, like Spring Security. After a GitHub login, `get_user(ctx, + 'google')` returns `None` and `require_auth('google')` redirects to the Google + login. +- `'multi-identity'`: each provider stays logged in independently. Use it for + linked accounts, aggregating several provider APIs, or step-up flows. Logging + out of one provider keeps the shared session alive while another provider is + still logged in. + +```python +oidc = OIDCProviders([google_config, github_config], session=session, + strategy='multi-identity') +``` + +Ephemeral flow keys (`state`, `nonce`, `code_verifier`) are never evicted, so +logins already in flight can still complete; under `'single-identity'` the last +callback to finish wins. Under either strategy, `get_users(ctx)` only reports +providers actually present in the session. The composable alternative is to create one `OIDCMixin` per provider, pointing each at distinct `login_path`/`callback_path`/`logout_path` values and sharing -the same `SessionMixin`: +the same `SessionMixin`. The same `strategy` keyword is accepted and defaults to +`'single-identity'`: ```python google = OIDCMixin(google_config, session=session) -github = OIDCMixin(github_config, session=session) +github = OIDCMixin(github_config, session=session, strategy='multi-identity') app = KayaApp(mixins=[session, google, github]) ``` @@ -117,6 +139,7 @@ app = KayaApp(mixins=[session, google, github]) - Refresh token support - RP-initiated logout (when provider advertises `end_session_endpoint`) - Multiple providers in one app via `OIDCProviders` (or several `OIDCMixin` instances) +- `single-identity` (default) or `multi-identity` session strategy - Composable with any other `KayaMixin` (RSGI, MCP, etc.) ## Security notes diff --git a/packages/kaya-oidc/src/kaya/oidc/__init__.py b/packages/kaya-oidc/src/kaya/oidc/__init__.py index b6eb6af..0a3f4c9 100644 --- a/packages/kaya-oidc/src/kaya/oidc/__init__.py +++ b/packages/kaya-oidc/src/kaya/oidc/__init__.py @@ -1,10 +1,11 @@ from ._client import OIDCClient from ._config import OIDCConfig -from ._mixin import OIDCMixin, OIDCUser +from ._mixin import IdentityStrategy, OIDCMixin, OIDCUser from ._providers import OIDCProviders __all__ = [ + 'IdentityStrategy', 'OIDCClient', 'OIDCConfig', 'OIDCMixin', diff --git a/packages/kaya-oidc/src/kaya/oidc/_mixin.py b/packages/kaya-oidc/src/kaya/oidc/_mixin.py index c5744be..f8c6470 100644 --- a/packages/kaya-oidc/src/kaya/oidc/_mixin.py +++ b/packages/kaya-oidc/src/kaya/oidc/_mixin.py @@ -1,4 +1,4 @@ -from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence, cast +from typing import Any, Awaitable, Callable, Literal, Mapping, Optional, Sequence, cast from kaya.core import HttpContext, KayaApp, KayaMixin from kaya.session import Session, SessionMixin @@ -10,8 +10,12 @@ from ._config import OIDCConfig type HttpHandler = Callable[..., Awaitable[None]] +type IdentityStrategy = Literal['single-identity', 'multi-identity'] + _SESSION_NAMESPACE = 'oidc' _USER_SUFFIX = 'user' +_IDENTITY_FIELDS = frozenset({'user', 'id_token', 'access_token', 'refresh_token'}) +_IDENTITY_STRATEGIES: tuple[IdentityStrategy, ...] = ('single-identity', 'multi-identity') class OIDCUser(Mapping[str, Any]): @@ -64,6 +68,12 @@ class OIDCMixin(KayaMixin): ``OIDCMixin`` per provider (with distinct route paths), or :class:`~kaya.oidc.OIDCProviders` to wire several at once. + ``strategy`` controls how many identities may coexist in the session. The + default ``'single-identity'`` evicts every other provider's identity after a + successful login, so the session has exactly one user (last login wins). + ``'multi-identity'`` keeps each provider logged in independently, which is + useful for linked accounts, provider API aggregation, and step-up flows. + Example:: session = SessionMixin(InMemorySessionStore()) @@ -77,16 +87,31 @@ class OIDCMixin(KayaMixin): ... """ - def __init__(self, config: OIDCConfig, session: SessionMixin) -> None: + def __init__( + self, + config: OIDCConfig, + session: SessionMixin, + strategy: IdentityStrategy = 'single-identity', + ) -> None: + if strategy not in _IDENTITY_STRATEGIES: + raise ValueError( + 'strategy must be one of ' + f'{", ".join(map(repr, _IDENTITY_STRATEGIES))} (got {strategy!r})' + ) self._config = config self._session = session self._client = OIDCClient(config) + self._strategy = strategy self._prefix = f'{_SESSION_NAMESPACE}:{config.provider}:' @property def provider(self) -> str: return self._config.provider + @property + def strategy(self) -> IdentityStrategy: + return self._strategy + def _key(self, name: str) -> str: return f'{self._prefix}{name}' @@ -158,6 +183,9 @@ class OIDCMixin(KayaMixin): if 'refresh_token' in token_response: session[self._key('refresh_token')] = token_response['refresh_token'] + if self._strategy == 'single-identity': + self._strip_other_identities(session) + session.regenerate_id() await ctx.send_empty(302, {'Location': self._config.post_login_redirect}) except ValueError as exc: @@ -184,6 +212,18 @@ class OIDCMixin(KayaMixin): if not any(_is_user_key(key) for key in session): session.invalidate() + def _strip_other_identities(self, session: Session) -> None: + """Remove every other provider's identity and tokens from the session. + + Only identity keys are dropped; ephemeral flow keys (``state``, + ``nonce``, ``code_verifier``) are left alone so logins already in flight + can still complete. Under this strategy the last callback to finish + wins. + """ + for key in [key for key in session + if _is_identity_key(key) and not key.startswith(self._prefix)]: + del session[key] + @staticmethod def _session_of(ctx: HttpContext) -> Session: session = ctx.session @@ -252,6 +292,11 @@ def _is_user_key(key: str) -> bool: return key.startswith(f'{_SESSION_NAMESPACE}:') and key.endswith(f':{_USER_SUFFIX}') +def _is_identity_key(key: str) -> bool: + return (key.startswith(f'{_SESSION_NAMESPACE}:') + and key.rsplit(':', 1)[-1] in _IDENTITY_FIELDS) + + def _provider_of_user_key(key: str) -> str: return key[len(_SESSION_NAMESPACE) + 1:-(len(_USER_SUFFIX) + 1)] diff --git a/packages/kaya-oidc/src/kaya/oidc/_providers.py b/packages/kaya-oidc/src/kaya/oidc/_providers.py index a354476..29a3af5 100644 --- a/packages/kaya-oidc/src/kaya/oidc/_providers.py +++ b/packages/kaya-oidc/src/kaya/oidc/_providers.py @@ -6,7 +6,13 @@ from kaya.core import HttpContext, KayaApp, KayaMixin from kaya.session import Session, SessionMixin from ._config import OIDCConfig -from ._mixin import HttpHandler, OIDCMixin, OIDCUser +from ._mixin import ( + _IDENTITY_STRATEGIES, + HttpHandler, + IdentityStrategy, + OIDCMixin, + OIDCUser, +) class OIDCProviders(KayaMixin): @@ -16,7 +22,13 @@ class OIDCProviders(KayaMixin): ``{base_path}/{provider}/login``, ``{base_path}/{provider}/callback`` and ``{base_path}/{provider}/logout``. All providers share the supplied :class:`~kaya.session.SessionMixin` and namespace their session state by - provider, so one provider's login or logout never disturbs another's. + provider. + + ``strategy`` controls how many identities may coexist in the session. The + default ``'single-identity'`` evicts every other provider's identity after a + successful login (last login wins). ``'multi-identity'`` keeps each provider + logged in independently, so logging out of one provider does not sign the + user out of the others. Example:: @@ -36,13 +48,20 @@ class OIDCProviders(KayaMixin): configs: Sequence[OIDCConfig], session: SessionMixin, base_path: str = '/auth', + strategy: IdentityStrategy = 'single-identity', ) -> None: if not configs: raise ValueError('At least one OIDCConfig is required') if not base_path.startswith('/'): raise ValueError('base_path must start with "/"') + if strategy not in _IDENTITY_STRATEGIES: + raise ValueError( + 'strategy must be one of ' + f'{", ".join(map(repr, _IDENTITY_STRATEGIES))} (got {strategy!r})' + ) self._session = session + self._strategy = strategy self._base_path = base_path.rstrip('/') self._providers: dict[str, OIDCMixin] = {} for config in configs: @@ -57,12 +76,17 @@ class OIDCProviders(KayaMixin): logout_path=f'{self._base_path}/{name}/logout', ), session=session, + strategy=strategy, ) @property def base_path(self) -> str: return self._base_path + @property + def strategy(self) -> IdentityStrategy: + return self._strategy + @property def providers(self) -> Mapping[str, OIDCMixin]: return MappingProxyType(self._providers) diff --git a/packages/kaya-oidc/tests/test_oidc.py b/packages/kaya-oidc/tests/test_oidc.py index 9a7d8f0..7c7ae78 100644 --- a/packages/kaya-oidc/tests/test_oidc.py +++ b/packages/kaya-oidc/tests/test_oidc.py @@ -381,7 +381,7 @@ class OIDCMultiProviderTest(unittest.TestCase): ) return config, http_client - def _build(self) -> tuple[KayaApp, OIDCProviders, InMemorySessionStore]: + def _build(self, strategy: str = 'multi-identity') -> tuple[KayaApp, OIDCProviders, InMemorySessionStore]: self.google = MockOIDCProvider('google', sub='g-sub', email='g@example.com') self.github = MockOIDCProvider('github', sub='h-sub', email='h@example.com') google_config, self.google_http = self._provider_config(self.google, 'google') @@ -389,7 +389,7 @@ class OIDCMultiProviderTest(unittest.TestCase): store = InMemorySessionStore() session = SessionMixin(store) - oidc = OIDCProviders([google_config, github_config], session=session) + oidc = OIDCProviders([google_config, github_config], session=session, strategy=strategy) app = KayaApp(mixins=[session, oidc]) @app.GET('/who') @@ -571,6 +571,123 @@ class OIDCMultiProviderTest(unittest.TestCase): r = await client.get('/who') self.assertEqual('google|google=g@example.com', r.text) + @async_test + async def test_single_identity_evicts_previous_provider(self) -> None: + app, oidc, store = self._build(strategy='single-identity') + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + await self._login(client, self.google, 'google') + r = await client.get('/google-profile') + self.assertEqual('g@example.com', r.text) + + # Logging in with github replaces the google identity. + await self._login(client, self.github, 'github') + + r = await client.get('/who') + self.assertEqual('github', r.text) + r = await client.get('/google-profile', follow_redirects=False) + self.assertEqual(302, r.status_code) + self.assertEqual('/auth/google/login', r.headers['Location']) + r = await client.get('/github-profile') + self.assertEqual('h@example.com', r.text) + + data = store._data[client.cookies['session_id']] + self.assertNotIn('oidc:google:user', data) + self.assertNotIn('oidc:google:id_token', data) + self.assertIn('oidc:github:user', data) + + @async_test + async def test_single_identity_last_completed_login_wins(self) -> None: + app, oidc, store = self._build(strategy='single-identity') + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + # Start both flows before completing either one; pending flow state + # is not evicted, so both callbacks can complete. + r = await client.get('/auth/google/login', follow_redirects=False) + google_query = parse_qs(urlparse(r.headers['Location']).query) + r = await client.get('/auth/github/login', follow_redirects=False) + github_query = parse_qs(urlparse(r.headers['Location']).query) + + self.google.nonce = google_query['nonce'][0] + self.github.nonce = github_query['nonce'][0] + + await self._complete_login(client, 'google', google_query['state'][0]) + await self._complete_login(client, 'github', github_query['state'][0]) + + r = await client.get('/who') + self.assertEqual('github', r.text) + + @async_test + async def test_standalone_mixins_default_to_single_identity(self) -> None: + google = MockOIDCProvider('google', sub='g-sub', email='g@example.com') + github = MockOIDCProvider('github', sub='h-sub', email='h@example.com') + google_config, google_http = self._provider_config(google, 'google') + github_config, github_http = self._provider_config(github, 'github') + + session = SessionMixin(InMemorySessionStore()) + google_mixin = OIDCMixin( + replace(google_config, login_path='/google/login', callback_path='/google/callback', logout_path='/google/logout'), + session=session, + ) + github_mixin = OIDCMixin( + replace(github_config, login_path='/github/login', callback_path='/github/callback', logout_path='/github/logout'), + session=session, + ) + app = KayaApp(mixins=[session, google_mixin, github_mixin]) + + self.assertEqual('single-identity', google_mixin.strategy) + + @app.GET('/g') + @google_mixin.require_auth + async def g(ctx: HttpContext) -> None: + await ctx.send_str(200, 'google') + + @app.GET('/h') + @github_mixin.require_auth + async def h(ctx: HttpContext) -> None: + await ctx.send_str(200, 'github') + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client: + r = await client.get('/google/login', follow_redirects=False) + query = parse_qs(urlparse(r.headers['Location']).query) + google.nonce = query['nonce'][0] + r = await client.get('/google/callback', params={'code': 'c', 'state': query['state'][0]}, follow_redirects=False) + self.assertEqual(302, r.status_code) + r = await client.get('/g') + self.assertEqual('google', r.text) + + r = await client.get('/github/login', follow_redirects=False) + query = parse_qs(urlparse(r.headers['Location']).query) + github.nonce = query['nonce'][0] + r = await client.get('/github/callback', params={'code': 'c', 'state': query['state'][0]}, follow_redirects=False) + self.assertEqual(302, r.status_code) + + r = await client.get('/g', follow_redirects=False) + self.assertEqual(302, r.status_code) + r = await client.get('/h') + self.assertEqual('github', r.text) + + def test_default_strategy_is_single_identity(self) -> None: + provider = MockOIDCProvider() + config, http = self._provider_config(provider, 'google') + session = SessionMixin(InMemorySessionStore()) + + providers = OIDCProviders([config], session=session) + self.assertEqual('single-identity', providers.strategy) + + mixin = OIDCMixin(config, session=session) + self.assertEqual('single-identity', mixin.strategy) + + def test_invalid_strategy_rejected(self) -> None: + provider = MockOIDCProvider() + config, http = self._provider_config(provider, 'google') + session = SessionMixin(InMemorySessionStore()) + with self.assertRaises(ValueError): + OIDCMixin(config, session=session, strategy='bogus') + with self.assertRaises(ValueError): + OIDCProviders([config], session=session, strategy='bogus') + def test_config_rejects_invalid_provider(self) -> None: with self.assertRaises(ValueError): OIDCConfig(