Show the OIDC profile picture with an identicon fallback
CI / Build and push docker image (push) Successful in 2m9s

GET /api/me now carries the profile picture URL when the provider
supplies an absolute http(s) one; the SPA shows it in the lobby navbar
and draws a deterministic identicon from the sub otherwise, falling back
to the identicon when the image fails to load.

The identicon is a 5x5 mirrored grid seeded by an inline FNV-1a hash so
it stays stable across Rust versions and is unit-testable on the host.
The dev mock IdP hands alice and bob a picture claim.
This commit is contained in:
2026-10-15 00:14:17 +08:00
parent a9eb97e6e1
commit 46f10f145a
13 changed files with 312 additions and 28 deletions
+13 -4
View File
@@ -24,25 +24,34 @@ _PROVIDER = next(iter(oidc.providers))
def make_user(
sub: str, name: Optional[str] = None, email: Optional[str] = None
sub: str,
name: Optional[str] = None,
email: Optional[str] = None,
picture: Optional[str] = None,
) -> OIDCUser:
claims = {"sub": sub, "preferred_username": name or sub}
if email:
claims["email"] = email
if picture:
claims["picture"] = picture
return OIDCUser(claims)
@contextlib.contextmanager
def oidc_user(
sub: str, name: Optional[str] = None, email: Optional[str] = None
sub: str,
name: Optional[str] = None,
email: Optional[str] = None,
picture: Optional[str] = None,
) -> Iterator[OIDCUser]:
"""Context manager: make ``oidc.get_users`` return this user.
``email`` is optional so tests can simulate an IdP that still returns
the claim: the app requests only the ``openid`` and ``profile`` scopes
and ignores email where present.
and ignores email where present. ``picture`` simulates the profile
scope's avatar claim.
"""
user = make_user(sub, name, email)
user = make_user(sub, name, email, picture)
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
patcher.start()
try:
+27 -2
View File
@@ -22,8 +22,33 @@ class MeRouteTest(unittest.TestCase):
with oidc_user("alice", email="alice@example.com"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
# The email claim the IdP returns is neither stored nor exposed.
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
# The email claim the IdP returns is neither stored nor exposed;
# no picture claim in this request.
self.assertEqual(
{"sub": "alice", "name": "alice", "picture": None},
response.json(),
)
@async_test
async def test_me_includes_the_profile_picture(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice", picture="https://cdn.example.com/alice.png"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual(
"https://cdn.example.com/alice.png",
response.json()["picture"],
)
@async_test
async def test_me_rejects_non_http_picture_claims(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice", picture="javascript:alert(1)"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertIsNone(response.json()["picture"])
@async_test
async def test_me_unauthenticated(self) -> None: