From 02b75a3ecaea9e800bf465cbe6ce18923348fab6 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Mon, 16 Feb 2026 12:52:43 +0100 Subject: [PATCH] feat: add OIDC claims mapping and PorchlightUserInfo source --- src/fastapi_oidc_op/config.py | 3 ++ src/fastapi_oidc_op/oidc/claims.py | 54 +++++++++++++++++++++++++ tests/test_oidc/test_claims.py | 65 ++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 src/fastapi_oidc_op/oidc/claims.py create mode 100644 tests/test_oidc/test_claims.py diff --git a/src/fastapi_oidc_op/config.py b/src/fastapi_oidc_op/config.py index 5ca9e1e..fb16930 100644 --- a/src/fastapi_oidc_op/config.py +++ b/src/fastapi_oidc_op/config.py @@ -35,5 +35,8 @@ class Settings(BaseSettings): # Magic links invite_ttl: int = 86400 # seconds + # Signing keys + signing_key_path: str = "data/keys" + # Theme theme: str = "default" diff --git a/src/fastapi_oidc_op/oidc/claims.py b/src/fastapi_oidc_op/oidc/claims.py new file mode 100644 index 0000000..c2b6b91 --- /dev/null +++ b/src/fastapi_oidc_op/oidc/claims.py @@ -0,0 +1,54 @@ +"""OIDC claims mapping and UserInfo source.""" + +from idpyoidc.server.user_info import UserInfo + +from fastapi_oidc_op.models import User + + +def user_to_claims(user: User) -> dict: + """Convert a User model to an OIDC claims dict. + + Only includes claims that have non-None values. + The 'sub' claim always uses the userid (proquint). + """ + claims: dict = {"sub": user.userid} + + # preferred_username: use explicit field, fall back to username + claims["preferred_username"] = user.preferred_username or user.username + + optional_fields = { + "given_name": user.given_name, + "family_name": user.family_name, + "nickname": user.nickname, + "email": user.email, + "email_verified": user.email_verified if user.email else None, + "phone_number": user.phone_number, + "phone_number_verified": user.phone_number_verified if user.phone_number else None, + "picture": user.picture, + "locale": user.locale, + } + + for claim_name, value in optional_fields.items(): + if value is not None: + claims[claim_name] = value + + # updated_at as Unix timestamp (OIDC spec requires number) + if user.updated_at: + claims["updated_at"] = int(user.updated_at.timestamp()) + + return claims + + +class PorchlightUserInfo(UserInfo): + """UserInfo source backed by an in-memory claims cache. + + Claims are populated via set_user_claims() after authentication. + idpyoidc calls __call__() synchronously to look up claims. + """ + + def __init__(self, **kwargs) -> None: + super().__init__(db={}, **kwargs) + + def set_user_claims(self, user_id: str, claims: dict) -> None: + """Populate claims cache for a user.""" + self.db[user_id] = claims diff --git a/tests/test_oidc/test_claims.py b/tests/test_oidc/test_claims.py new file mode 100644 index 0000000..69308e7 --- /dev/null +++ b/tests/test_oidc/test_claims.py @@ -0,0 +1,65 @@ +from datetime import UTC, datetime + +from fastapi_oidc_op.models import User +from fastapi_oidc_op.oidc.claims import PorchlightUserInfo, user_to_claims + + +def test_user_to_claims_minimal() -> None: + user = User(userid="lusab-bansen", username="alice") + claims = user_to_claims(user) + assert claims["sub"] == "lusab-bansen" + assert claims["preferred_username"] == "alice" + assert "email" not in claims # None fields excluded + + +def test_user_to_claims_full() -> None: + user = User( + userid="lusab-bansen", + username="alice", + preferred_username="Alice W.", + given_name="Alice", + family_name="Wonderland", + nickname="ali", + email="alice@example.com", + email_verified=True, + phone_number="+1234567890", + phone_number_verified=False, + picture="https://example.com/alice.jpg", + locale="en", + updated_at=datetime(2025, 1, 1, tzinfo=UTC), + ) + claims = user_to_claims(user) + assert claims["sub"] == "lusab-bansen" + assert claims["preferred_username"] == "Alice W." + assert claims["given_name"] == "Alice" + assert claims["family_name"] == "Wonderland" + assert claims["nickname"] == "ali" + assert claims["email"] == "alice@example.com" + assert claims["email_verified"] is True + assert claims["phone_number"] == "+1234567890" + assert claims["phone_number_verified"] is False + assert claims["picture"] == "https://example.com/alice.jpg" + assert claims["locale"] == "en" + assert claims["updated_at"] == int(datetime(2025, 1, 1, tzinfo=UTC).timestamp()) + + +def test_porchlight_userinfo_returns_claims() -> None: + userinfo = PorchlightUserInfo() + userinfo.set_user_claims("lusab-bansen", {"sub": "lusab-bansen", "email": "a@b.com"}) + result = userinfo("lusab-bansen", "client1") + assert result["sub"] == "lusab-bansen" + assert result["email"] == "a@b.com" + + +def test_porchlight_userinfo_filters_claims() -> None: + userinfo = PorchlightUserInfo() + userinfo.set_user_claims("lusab-bansen", {"sub": "lusab-bansen", "email": "a@b.com", "name": "Alice"}) + result = userinfo("lusab-bansen", "client1", user_info_claims={"email": None}) + assert "email" in result + assert "name" not in result + + +def test_porchlight_userinfo_unknown_user() -> None: + userinfo = PorchlightUserInfo() + result = userinfo("unknown", "client1") + assert result == {}