feat: add OIDC claims mapping and PorchlightUserInfo source
This commit is contained in:
parent
fd098a4eff
commit
02b75a3eca
3 changed files with 122 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
54
src/fastapi_oidc_op/oidc/claims.py
Normal file
54
src/fastapi_oidc_op/oidc/claims.py
Normal file
|
|
@ -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
|
||||
65
tests/test_oidc/test_claims.py
Normal file
65
tests/test_oidc/test_claims.py
Normal file
|
|
@ -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 == {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue