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))
```
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
+2 -1
View File
@@ -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',
+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.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)]
+26 -2
View File
@@ -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)
+119 -2
View File
@@ -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(