OIDCConfig now asks for the openid and profile scopes only: kaya-oidc's default would also request email, which the platform never stores or exposes. The display-name fallback drops the email claim too (preferred_username, name, then the sub). Tests pin the configured scopes and cover an IdP that still returns an email claim: the claim is ignored and absent from /api/me.
187 lines
6.0 KiB
Python
187 lines
6.0 KiB
Python
"""Tests for the auth helpers' OIDC user lookup and gating.
|
|
|
|
kaya-oidc (0.0.6) namespaces session state per provider as
|
|
``oidc:{provider}:user`` and tavolo wires an :class:`OIDCProviders`; these
|
|
tests pin both contracts so a kaya upgrade or a provider change cannot
|
|
silently break HTTP or websocket authentication.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
|
|
from kaya.session import InMemorySessionStore, Session, SessionMixin
|
|
|
|
from helpers import async_test
|
|
from tavolo.platform.auth import (
|
|
current_user,
|
|
display_name,
|
|
get_ws_user,
|
|
require_auth,
|
|
session_user,
|
|
)
|
|
|
|
|
|
def _providers(*names: str) -> OIDCProviders:
|
|
session = SessionMixin(InMemorySessionStore())
|
|
return OIDCProviders(
|
|
[
|
|
OIDCConfig(
|
|
provider=name,
|
|
issuer=f"http://localhost:8180/{name}",
|
|
client_id="tavolo",
|
|
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
|
|
)
|
|
for name in names
|
|
],
|
|
session=session,
|
|
)
|
|
|
|
|
|
def _ctx(data: Any) -> Any:
|
|
return SimpleNamespace(session=Session("sid", data))
|
|
|
|
|
|
def _ws(session: Any) -> Any:
|
|
return SimpleNamespace(session=session)
|
|
|
|
|
|
class SessionUserTest(unittest.TestCase):
|
|
def test_finds_default_provider_user(self) -> None:
|
|
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
|
|
assert user is not None
|
|
self.assertEqual("alice", user.sub)
|
|
|
|
def test_finds_user_of_any_provider(self) -> None:
|
|
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
|
|
assert user is not None
|
|
self.assertEqual("alice", user.sub)
|
|
|
|
def test_ignores_flow_state_and_tokens(self) -> None:
|
|
session = Session(
|
|
"sid",
|
|
{
|
|
"oidc:default:state": "state",
|
|
"oidc:default:id_token": "token",
|
|
"oidc:default:access_token": "token",
|
|
},
|
|
)
|
|
self.assertIsNone(session_user(session))
|
|
|
|
def test_ignores_legacy_unprefixed_user_key(self) -> None:
|
|
session = Session("sid", {"oidc_user": {"sub": "alice"}})
|
|
self.assertIsNone(session_user(session))
|
|
|
|
def test_ignores_malformed_user_claims(self) -> None:
|
|
session = Session("sid", {"oidc:default:user": "alice"})
|
|
self.assertIsNone(session_user(session))
|
|
|
|
|
|
class GetWsUserTest(unittest.TestCase):
|
|
def test_returns_user_from_provider_namespaced_session(self) -> None:
|
|
session = Session(
|
|
"sid",
|
|
{"oidc:default:user": {"sub": "alice", "preferred_username": "Alice"}},
|
|
)
|
|
user = get_ws_user(_ws(session))
|
|
assert user is not None
|
|
self.assertEqual("alice", user.sub)
|
|
|
|
def test_missing_session_returns_none(self) -> None:
|
|
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
|
|
|
|
|
class DisplayNameTest(unittest.TestCase):
|
|
"""The claim priority used for the name shown at the table."""
|
|
|
|
def test_prefers_preferred_username_over_name(self) -> None:
|
|
user = OIDCUser(
|
|
{"sub": "alice", "preferred_username": "alice", "name": "Alice"}
|
|
)
|
|
self.assertEqual("alice", display_name(user))
|
|
|
|
def test_falls_back_to_name(self) -> None:
|
|
user = OIDCUser({"sub": "alice", "name": "Alice"})
|
|
self.assertEqual("Alice", display_name(user))
|
|
|
|
def test_ignores_email(self) -> None:
|
|
# The app requests only the openid and profile scopes; an email
|
|
# claim the provider still returns must not leak into the name.
|
|
user = OIDCUser({"sub": "alice", "email": "alice@example.com"})
|
|
self.assertEqual("alice", display_name(user))
|
|
|
|
def test_falls_back_to_sub(self) -> None:
|
|
user = OIDCUser({"sub": "alice"})
|
|
self.assertEqual("alice", display_name(user))
|
|
|
|
def test_ignores_empty_and_non_string_claims(self) -> None:
|
|
user = OIDCUser({"sub": "alice", "preferred_username": "", "name": 42})
|
|
self.assertEqual("alice", display_name(user))
|
|
|
|
|
|
class CurrentUserTest(unittest.TestCase):
|
|
def test_anonymous_session_has_no_user(self) -> None:
|
|
self.assertIsNone(current_user(_providers("default"), _ctx({})))
|
|
|
|
def test_returns_the_authenticated_provider_user(self) -> None:
|
|
user = current_user(
|
|
_providers("default", "google"),
|
|
_ctx({"oidc:google:user": {"sub": "alice"}}),
|
|
)
|
|
assert user is not None
|
|
self.assertEqual("alice", user.sub)
|
|
|
|
def test_first_configured_provider_wins(self) -> None:
|
|
# Single-identity sessions never hold two users; this only pins the
|
|
# tie-break should they ever coexist.
|
|
user = current_user(
|
|
_providers("default", "google"),
|
|
_ctx(
|
|
{
|
|
"oidc:default:user": {"sub": "alice"},
|
|
"oidc:google:user": {"sub": "bob"},
|
|
}
|
|
),
|
|
)
|
|
assert user is not None
|
|
self.assertEqual("alice", user.sub)
|
|
|
|
|
|
class RequireAuthTest(unittest.TestCase):
|
|
@async_test
|
|
async def test_rejects_anonymous_requests(self) -> None:
|
|
oidc = _providers("default")
|
|
ctx = _ctx({})
|
|
sent = {}
|
|
|
|
async def send_bytes(*args: Any) -> None:
|
|
sent["status"] = args[0]
|
|
|
|
ctx.send_bytes = send_bytes
|
|
handled = []
|
|
|
|
@require_auth(oidc)
|
|
async def handler(_ctx: Any) -> None:
|
|
handled.append(True)
|
|
|
|
await handler(ctx)
|
|
self.assertEqual(401, sent["status"])
|
|
self.assertEqual([], handled)
|
|
|
|
@async_test
|
|
async def test_allows_authenticated_requests(self) -> None:
|
|
oidc = _providers("default")
|
|
ctx = _ctx({"oidc:default:user": {"sub": "alice"}})
|
|
handled = []
|
|
|
|
@require_auth(oidc)
|
|
async def handler(request_ctx: Any) -> None:
|
|
user = current_user(oidc, request_ctx)
|
|
assert user is not None
|
|
handled.append(user.sub)
|
|
|
|
await handler(ctx)
|
|
self.assertEqual(["alice"], handled)
|