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:
5 files changed
+224
-14
No files matched your search
@@ -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