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:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user