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