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.
This commit is contained in:
2026-10-01 08:23:40 +08:00
parent 3af2e7a975
commit 15dc4b6dad
5 changed files with 224 additions and 14 deletions
+30 -7
View File
@@ -86,19 +86,41 @@ async def who(ctx: HttpContext):
await ctx.send_str(200, ', '.join(users)) await ctx.send_str(200, ', '.join(users))
``` ```
Each provider's session state is namespaced as `oidc:{provider}:...`, so a user Each provider's session state is namespaced as `oidc:{provider}:...`. Make sure
can be logged into several providers at once. Logging out of one provider only each config's `redirect_uri` points at that provider's derived callback path.
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 ### Identity strategy
that provider's derived callback path.
`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 The composable alternative is to create one `OIDCMixin` per provider, pointing
each at distinct `login_path`/`callback_path`/`logout_path` values and sharing 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 ```python
google = OIDCMixin(google_config, session=session) 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]) app = KayaApp(mixins=[session, google, github])
``` ```
@@ -117,6 +139,7 @@ app = KayaApp(mixins=[session, google, github])
- Refresh token support - Refresh token support
- RP-initiated logout (when provider advertises `end_session_endpoint`) - RP-initiated logout (when provider advertises `end_session_endpoint`)
- Multiple providers in one app via `OIDCProviders` (or several `OIDCMixin` instances) - 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.) - Composable with any other `KayaMixin` (RSGI, MCP, etc.)
## Security notes ## Security notes
+2 -1
View File
@@ -1,10 +1,11 @@
from ._client import OIDCClient from ._client import OIDCClient
from ._config import OIDCConfig from ._config import OIDCConfig
from ._mixin import OIDCMixin, OIDCUser from ._mixin import IdentityStrategy, OIDCMixin, OIDCUser
from ._providers import OIDCProviders from ._providers import OIDCProviders
__all__ = [ __all__ = [
'IdentityStrategy',
'OIDCClient', 'OIDCClient',
'OIDCConfig', 'OIDCConfig',
'OIDCMixin', 'OIDCMixin',
+47 -2
View File
@@ -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.core import HttpContext, KayaApp, KayaMixin
from kaya.session import Session, SessionMixin from kaya.session import Session, SessionMixin
@@ -10,8 +10,12 @@ from ._config import OIDCConfig
type HttpHandler = Callable[..., Awaitable[None]] type HttpHandler = Callable[..., Awaitable[None]]
type IdentityStrategy = Literal['single-identity', 'multi-identity']
_SESSION_NAMESPACE = 'oidc' _SESSION_NAMESPACE = 'oidc'
_USER_SUFFIX = 'user' _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]): class OIDCUser(Mapping[str, Any]):
@@ -64,6 +68,12 @@ class OIDCMixin(KayaMixin):
``OIDCMixin`` per provider (with distinct route paths), or ``OIDCMixin`` per provider (with distinct route paths), or
:class:`~kaya.oidc.OIDCProviders` to wire several at once. :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:: Example::
session = SessionMixin(InMemorySessionStore()) 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._config = config
self._session = session self._session = session
self._client = OIDCClient(config) self._client = OIDCClient(config)
self._strategy = strategy
self._prefix = f'{_SESSION_NAMESPACE}:{config.provider}:' self._prefix = f'{_SESSION_NAMESPACE}:{config.provider}:'
@property @property
def provider(self) -> str: def provider(self) -> str:
return self._config.provider return self._config.provider
@property
def strategy(self) -> IdentityStrategy:
return self._strategy
def _key(self, name: str) -> str: def _key(self, name: str) -> str:
return f'{self._prefix}{name}' return f'{self._prefix}{name}'
@@ -158,6 +183,9 @@ class OIDCMixin(KayaMixin):
if 'refresh_token' in token_response: if 'refresh_token' in token_response:
session[self._key('refresh_token')] = token_response['refresh_token'] session[self._key('refresh_token')] = token_response['refresh_token']
if self._strategy == 'single-identity':
self._strip_other_identities(session)
session.regenerate_id() session.regenerate_id()
await ctx.send_empty(302, {'Location': self._config.post_login_redirect}) await ctx.send_empty(302, {'Location': self._config.post_login_redirect})
except ValueError as exc: except ValueError as exc:
@@ -184,6 +212,18 @@ class OIDCMixin(KayaMixin):
if not any(_is_user_key(key) for key in session): if not any(_is_user_key(key) for key in session):
session.invalidate() 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 @staticmethod
def _session_of(ctx: HttpContext) -> Session: def _session_of(ctx: HttpContext) -> Session:
session = ctx.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}') 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: def _provider_of_user_key(key: str) -> str:
return key[len(_SESSION_NAMESPACE) + 1:-(len(_USER_SUFFIX) + 1)] return key[len(_SESSION_NAMESPACE) + 1:-(len(_USER_SUFFIX) + 1)]
+26 -2
View File
@@ -6,7 +6,13 @@ from kaya.core import HttpContext, KayaApp, KayaMixin
from kaya.session import Session, SessionMixin from kaya.session import Session, SessionMixin
from ._config import OIDCConfig from ._config import OIDCConfig
from ._mixin import HttpHandler, OIDCMixin, OIDCUser from ._mixin import (
_IDENTITY_STRATEGIES,
HttpHandler,
IdentityStrategy,
OIDCMixin,
OIDCUser,
)
class OIDCProviders(KayaMixin): class OIDCProviders(KayaMixin):
@@ -16,7 +22,13 @@ class OIDCProviders(KayaMixin):
``{base_path}/{provider}/login``, ``{base_path}/{provider}/callback`` and ``{base_path}/{provider}/login``, ``{base_path}/{provider}/callback`` and
``{base_path}/{provider}/logout``. All providers share the supplied ``{base_path}/{provider}/logout``. All providers share the supplied
:class:`~kaya.session.SessionMixin` and namespace their session state by :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:: Example::
@@ -36,13 +48,20 @@ class OIDCProviders(KayaMixin):
configs: Sequence[OIDCConfig], configs: Sequence[OIDCConfig],
session: SessionMixin, session: SessionMixin,
base_path: str = '/auth', base_path: str = '/auth',
strategy: IdentityStrategy = 'single-identity',
) -> None: ) -> None:
if not configs: if not configs:
raise ValueError('At least one OIDCConfig is required') raise ValueError('At least one OIDCConfig is required')
if not base_path.startswith('/'): if not base_path.startswith('/'):
raise ValueError('base_path must start with "/"') 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._session = session
self._strategy = strategy
self._base_path = base_path.rstrip('/') self._base_path = base_path.rstrip('/')
self._providers: dict[str, OIDCMixin] = {} self._providers: dict[str, OIDCMixin] = {}
for config in configs: for config in configs:
@@ -57,12 +76,17 @@ class OIDCProviders(KayaMixin):
logout_path=f'{self._base_path}/{name}/logout', logout_path=f'{self._base_path}/{name}/logout',
), ),
session=session, session=session,
strategy=strategy,
) )
@property @property
def base_path(self) -> str: def base_path(self) -> str:
return self._base_path return self._base_path
@property
def strategy(self) -> IdentityStrategy:
return self._strategy
@property @property
def providers(self) -> Mapping[str, OIDCMixin]: def providers(self) -> Mapping[str, OIDCMixin]:
return MappingProxyType(self._providers) return MappingProxyType(self._providers)
+119 -2
View File
@@ -381,7 +381,7 @@ class OIDCMultiProviderTest(unittest.TestCase):
) )
return config, http_client 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.google = MockOIDCProvider('google', sub='g-sub', email='g@example.com')
self.github = MockOIDCProvider('github', sub='h-sub', email='h@example.com') self.github = MockOIDCProvider('github', sub='h-sub', email='h@example.com')
google_config, self.google_http = self._provider_config(self.google, 'google') google_config, self.google_http = self._provider_config(self.google, 'google')
@@ -389,7 +389,7 @@ class OIDCMultiProviderTest(unittest.TestCase):
store = InMemorySessionStore() store = InMemorySessionStore()
session = SessionMixin(store) 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 = KayaApp(mixins=[session, oidc])
@app.GET('/who') @app.GET('/who')
@@ -571,6 +571,123 @@ class OIDCMultiProviderTest(unittest.TestCase):
r = await client.get('/who') r = await client.get('/who')
self.assertEqual('google|google=g@example.com', r.text) 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: def test_config_rejects_invalid_provider(self) -> None:
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
OIDCConfig( OIDCConfig(