diff --git a/README.md b/README.md index ef450f9..9ed45db 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,10 @@ from client.models import CreateAPISSOUserData # Create and configure the API client config = Configuration() -config.host = "https://fastcomments.com/api" +config.host = "https://fastcomments.com" # REQUIRED: Set your API key (get this from your FastComments dashboard) -config.api_key = {"ApiKeyAuth": "YOUR_API_KEY_HERE"} +config.api_key = {"api_key": "YOUR_API_KEY_HERE"} # Create the API instance with the configured client api_client = ApiClient(configuration=config) @@ -78,7 +78,7 @@ Public endpoints don't require authentication: from client import ApiClient, Configuration, PublicApi config = Configuration() -config.host = "https://fastcomments.com/api" +config.host = "https://fastcomments.com" api_client = ApiClient(configuration=config) public_api = PublicApi(api_client) @@ -99,7 +99,7 @@ from client import ApiClient, Configuration, ModerationApi from client.api.moderation_api import GetCountOptions config = Configuration() -config.host = "https://fastcomments.com/api" +config.host = "https://fastcomments.com" api_client = ApiClient(configuration=config) moderation_api = ModerationApi(api_client) @@ -119,21 +119,18 @@ The SDK includes utilities for generating secure SSO tokens: ```python from sso import FastCommentsSSO, SecureSSOUserData -# Create user data +# Create user data (id, email, and username are required) user_data = SecureSSOUserData( - user_id="user-123", + id="user-123", email="user@example.com", username="johndoe", avatar="https://example.com/avatar.jpg" ) -# Create SSO instance with your API secret -sso = FastCommentsSSO.new_secure( - api_secret="YOUR_API_SECRET", - user_data=user_data -) +# Sign it with your API secret (HMAC-SHA256) +sso = FastCommentsSSO.new_secure("YOUR_API_SECRET", user_data) -# Generate the SSO token +# Generate the SSO token to pass to the widget or an API call sso_token = sso.create_token() # Use this token in your frontend or pass to API calls @@ -146,7 +143,7 @@ For simple SSO (less secure, for testing): from sso import FastCommentsSSO, SimpleSSOUserData user_data = SimpleSSOUserData( - user_id="user-123", + username="johndoe", email="user@example.com" ) @@ -156,7 +153,7 @@ sso_token = sso.create_token() ### Common Issues -1. **401 "missing-api-key" error**: Make sure you set `config.api_key = {"ApiKeyAuth": "YOUR_KEY"}` before creating the DefaultApi instance. +1. **401 "missing-api-key" error**: Make sure you set `config.api_key = {"api_key": "YOUR_KEY"}` before creating the DefaultApi instance. 2. **Wrong API class**: Use `DefaultApi` for server-side authenticated requests, `PublicApi` for client-side/public requests, and `ModerationApi` for moderator dashboard requests. 3. **Import errors**: Make sure you're importing from the correct module: - API client: `from client import ...` @@ -198,10 +195,15 @@ You'll see you're supposed to pass a `broadcast_id` in some API calls. When you ## Requirements - Python >= 3.8 -- urllib3 >= 1.25.3 -- python-dateutil >= 2.8.2 -- pydantic >= 2.0.0 -- typing-extensions >= 4.0.0 + +The base install is pure-stdlib and provides the SSO utilities. The generated +API client (`DefaultApi`/`PublicApi`/`ModerationApi`) needs the `client` extra, +which pulls in `urllib3 >= 1.25.3`, `python-dateutil >= 2.8.2`, +`pydantic >= 2.0.0`, and `typing-extensions >= 4.0.0`: + +```bash +pip install "fastcomments[client] @ git+https://github.com/fastcomments/fastcomments-python.git@v3.0.0" +``` ## License diff --git a/pyproject.toml b/pyproject.toml index 8a3af6d..b24d596 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,15 +22,20 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = [ +# Base install is the pure-stdlib SSO module (no third-party deps). The +# generated OpenAPI client needs the heavier stack below; install it with the +# `client` extra: pip install "fastcomments[client] @ git+..." +dependencies = [] + +[project.optional-dependencies] +client = [ "urllib3>=1.25.3", "python-dateutil>=2.8.2", "pydantic>=2.0.0", "typing-extensions>=4.0.0", ] - -[project.optional-dependencies] dev = [ + "fastcomments[client]", "pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", diff --git a/sso/fastcomments_sso.py b/sso/fastcomments_sso.py index b86e919..694fa5c 100644 --- a/sso/fastcomments_sso.py +++ b/sso/fastcomments_sso.py @@ -15,18 +15,41 @@ def __init__(self, secure_sso_payload, simple_sso_user_data, login_url: str | No self.cached_token = None @classmethod - def new_secure(cls, api_key: str, secure_sso_user_data: SecureSSOUserData): - timestamp = int(time.time()) + def new_secure( + cls, + api_key: str, + secure_sso_user_data: SecureSSOUserData, + login_url: str | None = None, + logout_url: str | None = None, + ): + # Epoch MILLISECONDS. The server rejects payloads more than two days old + # and hashes this exact value, so seconds would fail verification. + timestamp = int(time.time() * 1000) user_data_str = secure_sso_user_data.as_json_base64() hash = create_verification_hash(api_key, timestamp, user_data_str) - payload = SecureSSOPayload(user_data_str, hash, timestamp) - return cls(secure_sso_payload = payload, simple_sso_user_data = None) + payload = SecureSSOPayload(user_data_str, hash, timestamp, login_url, logout_url) + return cls( + secure_sso_payload=payload, + simple_sso_user_data=None, + login_url=login_url, + logout_url=logout_url, + ) @classmethod - def new_simple(cls, simple_sso_user_data: SimpleSSOUserData): - return cls(secure_sso_payload = None, simple_sso_user_data = simple_sso_user_data) + def new_simple( + cls, + simple_sso_user_data: SimpleSSOUserData, + login_url: str | None = None, + logout_url: str | None = None, + ): + return cls( + secure_sso_payload=None, + simple_sso_user_data=simple_sso_user_data, + login_url=login_url, + logout_url=logout_url, + ) @classmethod def new_secure_with_urls( diff --git a/sso/secure_sso_payload.py b/sso/secure_sso_payload.py index 8a442cd..178e063 100644 --- a/sso/secure_sso_payload.py +++ b/sso/secure_sso_payload.py @@ -1,14 +1,40 @@ import json + class SecureSSOPayload: - def __init__(self, user_data_json_base64: str, verification_hash: str, timestamp: int): + """A signed Secure SSO payload in the FastComments wire format. + + On the wire the keys are camelCase (`userDataJSONBase64`, + `verificationHash`, `timestamp`) with `timestamp` in epoch milliseconds; + `loginURL`/`logoutURL` are included only when set. + """ + + def __init__( + self, + user_data_json_base64: str, + verification_hash: str, + timestamp: int, + login_url: str = None, + logout_url: str = None, + ): self.user_data_json_base64 = user_data_json_base64 self.verification_hash = verification_hash self.timestamp = timestamp + self.login_url = login_url + self.logout_url = logout_url + + def to_widget_dict(self) -> dict: + """The object the widget expects at `config.sso`.""" + result = { + "userDataJSONBase64": self.user_data_json_base64, + "verificationHash": self.verification_hash, + "timestamp": self.timestamp, + } + if self.login_url is not None: + result["loginURL"] = self.login_url + if self.logout_url is not None: + result["logoutURL"] = self.logout_url + return result - def toJSON(self): - return json.dumps( - self, - default=lambda o: o.__dict__, - sort_keys=True, - indent=4) \ No newline at end of file + def toJSON(self) -> str: + return json.dumps(self.to_widget_dict(), separators=(",", ":")) diff --git a/sso/secure_sso_user_data.py b/sso/secure_sso_user_data.py index f6d264c..b8eaa2e 100644 --- a/sso/secure_sso_user_data.py +++ b/sso/secure_sso_user_data.py @@ -1,23 +1,74 @@ import json import base64 + class SecureSSOUserData: - def __init__(self, user_id: str, email: str, username: str, avatar: str): - self.user_id = user_id + """User identity for Secure SSO. + + Serializes to the exact JSON the FastComments server validates: keyed on + `id` (required), compact, camelCase optional fields, with unset optionals + omitted. `id` is always stringified. + """ + + # snake_case constructor arg -> camelCase wire key, in serialization order. + _FIELDS = ( + ("id", "id"), + ("email", "email"), + ("username", "username"), + ("avatar", "avatar"), + ("display_name", "displayName"), + ("display_label", "displayLabel"), + ("website_url", "websiteUrl"), + ("group_ids", "groupIds"), + ("is_admin", "isAdmin"), + ("is_moderator", "isModerator"), + ("opted_in_notifications", "optedInNotifications"), + ("is_profile_activity_private", "isProfileActivityPrivate"), + ) + + def __init__( + self, + id=None, + email=None, + username=None, + avatar=None, + display_name=None, + display_label=None, + website_url=None, + group_ids=None, + is_admin=None, + is_moderator=None, + opted_in_notifications=None, + is_profile_activity_private=None, + **extra, + ): + self.id = None if id is None else str(id) self.email = email self.username = username self.avatar = avatar - - def toJSON(self): - return json.dumps( - self, - default=lambda o: o.__dict__, - sort_keys=True, - indent=4) - + self.display_name = display_name + self.display_label = display_label + self.website_url = website_url + self.group_ids = group_ids + self.is_admin = is_admin + self.is_moderator = is_moderator + self.opted_in_notifications = opted_in_notifications + self.is_profile_activity_private = is_profile_activity_private + # Escape hatch for widget SSO fields not modeled above; assumed to be + # already wire-ready (camelCase) keys. None values are dropped. + self.extra = {k: v for k, v in extra.items() if v is not None} + + def to_dict(self) -> dict: + result = {} + for attr, wire_key in self._FIELDS: + value = getattr(self, attr) + if value is not None: + result[wire_key] = value + result.update(self.extra) + return result + + def toJSON(self) -> str: + return json.dumps(self.to_dict(), separators=(",", ":")) + def as_json_base64(self) -> str: - json_str = self.toJSON() - json_bytes = json_str.encode("utf-8") - - result = base64.b64encode(json_bytes) - return result.decode("utf-8") \ No newline at end of file + return base64.b64encode(self.toJSON().encode("utf-8")).decode("utf-8") diff --git a/sso/simple_sso_user_data.py b/sso/simple_sso_user_data.py index 2b098bf..09296ec 100644 --- a/sso/simple_sso_user_data.py +++ b/sso/simple_sso_user_data.py @@ -1,14 +1,45 @@ import json + class SimpleSSOUserData: - def __init__(self, user_id: str, email: str, avatar: str): - self.user_id = user_id + """User identity for Simple (unsigned) SSO. + + The widget's `simpleSSO` object is keyed on `username`; unset optionals are + omitted. This is less secure than Secure SSO and carries no signature. + """ + + _FIELDS = ( + ("username", "username"), + ("email", "email"), + ("avatar", "avatar"), + ("website_url", "websiteUrl"), + ("display_name", "displayName"), + ) + + def __init__( + self, + username=None, + email=None, + avatar=None, + website_url=None, + display_name=None, + **extra, + ): + self.username = username self.email = email self.avatar = avatar - - def toJSON(self): - return json.dumps( - self, - default=lambda o: o.__dict__, - sort_keys=True, - indent=4) \ No newline at end of file + self.website_url = website_url + self.display_name = display_name + self.extra = {k: v for k, v in extra.items() if v is not None} + + def to_dict(self) -> dict: + result = {} + for attr, wire_key in self._FIELDS: + value = getattr(self, attr) + if value is not None: + result[wire_key] = value + result.update(self.extra) + return result + + def toJSON(self) -> str: + return json.dumps(self.to_dict(), separators=(",", ":")) diff --git a/tests/conftest.py b/tests/conftest.py index 264007b..2cf4714 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,7 +32,7 @@ def base_url(): def test_user_data(): """Provide sample user data for SSO tests.""" return { - "user_id": "test-user-123", + "id": "test-user-123", "email": "test@example.com", "username": "testuser", "avatar": "https://example.com/avatar.jpg" diff --git a/tests/env.py b/tests/env.py index f686036..78f6646 100644 --- a/tests/env.py +++ b/tests/env.py @@ -20,7 +20,12 @@ def get_optional_env_var(name: str, default_value: str) -> str: return os.getenv(name, default_value) -# Test environment configuration -API_KEY = get_required_env_var("FASTCOMMENTS_API_KEY") -TENANT_ID = get_required_env_var("FASTCOMMENTS_TENANT_ID") +# Test environment configuration. +# Unit tests (SSO signing) run fully offline, so credentials are optional here; +# integration tests skip themselves when real credentials are absent. +API_KEY = get_optional_env_var("FASTCOMMENTS_API_KEY", "test-api-secret") +TENANT_ID = get_optional_env_var("FASTCOMMENTS_TENANT_ID", "demo") BASE_URL = get_optional_env_var("FASTCOMMENTS_BASE_URL", "https://fastcomments.com") + +# True only when real credentials were supplied (used to gate integration tests). +HAS_CREDENTIALS = bool(os.getenv("FASTCOMMENTS_API_KEY") and os.getenv("FASTCOMMENTS_TENANT_ID")) diff --git a/tests/test_sso.py b/tests/test_sso.py index d48af07..c41024d 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -1,4 +1,12 @@ -"""Unit tests for FastComments SSO functionality.""" +"""Unit tests for FastComments SSO functionality. + +These assert the on-the-wire format the FastComments server actually verifies +(see server util/sso-utils.ts decryptSSOPayload): the signed message is +`str(timestamp_ms) + userDataJSONBase64`, the payload keys are camelCase +(userDataJSONBase64/verificationHash/timestamp), the user object is keyed on +`id`, and the timestamp is epoch milliseconds. The pinned parity vector below +must stay byte-identical across the JS/PHP/Java SDKs. +""" import json import base64 @@ -13,42 +21,47 @@ SecureSSOUserData, SimpleSSOUserData, create_verification_hash, - CreateHashError + CreateHashError, ) +# Pinned known-answer vector. Regenerate with the same algorithm in any SDK: +# msg = str(TS) + base64(compact_json(USER)); HMAC-SHA256(SECRET, msg).hexdigest() +PARITY_SECRET = "test-api-secret" +PARITY_TS = 1700000000000 # epoch MILLISECONDS +PARITY_USER_JSON = '{"id":"123","email":"test@example.com","username":"tester"}' +PARITY_USER_B64 = base64.b64encode(PARITY_USER_JSON.encode("utf-8")).decode("utf-8") +PARITY_HASH = "f7e13d510e7f0cb11f027ce8161b8dc0f380df4aa85213ed15a973e34cb9dc2f" + + class TestHelpers: """Test helper functions.""" - def test_create_verification_hash(self, api_key): - """Test that create_verification_hash produces valid HMAC-SHA256.""" - timestamp = int(time.time()) + def test_create_verification_hash(self): + """create_verification_hash produces lowercase-hex HMAC-SHA256.""" + timestamp = 1700000000000 user_data = "test_data_base64" - result = create_verification_hash(api_key, timestamp, user_data) + result = create_verification_hash(PARITY_SECRET, timestamp, user_data) - # Verify it's a valid hex string assert isinstance(result, str) - assert len(result) == 64 # SHA256 hex is 64 characters + assert len(result) == 64 + assert result == result.lower() - # Verify the hash manually message_str = f"{timestamp}{user_data}" - mac = hmac.new( - key=api_key.encode('utf-8'), - msg=message_str.encode('utf-8'), - digestmod=hashlib.sha256 - ) - expected_hash = mac.digest().hex() + expected = hmac.new( + PARITY_SECRET.encode("utf-8"), + message_str.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + assert result == expected - assert result == expected_hash + def test_parity_vector(self): + """The signer must match the pinned cross-SDK vector byte-for-byte.""" + assert create_verification_hash(PARITY_SECRET, PARITY_TS, PARITY_USER_B64) == PARITY_HASH def test_create_verification_hash_with_empty_api_key(self): - """Test hash creation with empty API key.""" - timestamp = int(time.time()) - user_data = "test_data" - - # Should not raise error with empty key - result = create_verification_hash("", timestamp, user_data) + result = create_verification_hash("", 1700000000000, "test_data") assert isinstance(result, str) assert len(result) == 64 @@ -56,296 +69,186 @@ def test_create_verification_hash_with_empty_api_key(self): class TestSecureSSOUserData: """Test SecureSSOUserData class.""" - def test_create_user_data(self, test_user_data): - """Test creating SecureSSOUserData instance.""" - user = SecureSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - username=test_user_data["username"], - avatar=test_user_data["avatar"] - ) - - assert user.user_id == test_user_data["user_id"] - assert user.email == test_user_data["email"] - assert user.username == test_user_data["username"] - assert user.avatar == test_user_data["avatar"] + def test_serializes_id_key_not_user_id(self): + """The server requires `id`; `user_id` must never appear on the wire.""" + user = SecureSSOUserData(id="123", email="test@example.com", username="tester") + parsed = json.loads(user.toJSON()) - def test_to_json(self, test_user_data): - """Test JSON serialization.""" - user = SecureSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - username=test_user_data["username"], - avatar=test_user_data["avatar"] - ) + assert parsed["id"] == "123" + assert "user_id" not in parsed + assert parsed["email"] == "test@example.com" + assert parsed["username"] == "tester" - json_str = user.toJSON() - parsed = json.loads(json_str) + def test_matches_parity_json(self): + """Compact serialization must equal the pinned vector's base64.""" + user = SecureSSOUserData(id="123", email="test@example.com", username="tester") + assert user.as_json_base64() == PARITY_USER_B64 - assert parsed["user_id"] == test_user_data["user_id"] - assert parsed["email"] == test_user_data["email"] - assert parsed["username"] == test_user_data["username"] - assert parsed["avatar"] == test_user_data["avatar"] + def test_id_is_stringified(self): + user = SecureSSOUserData(id=123, email="a@b.com", username="u") + parsed = json.loads(user.toJSON()) + assert parsed["id"] == "123" + assert isinstance(parsed["id"], str) - def test_as_json_base64(self, test_user_data): - """Test base64 encoding of JSON data.""" + def test_optional_fields_use_camelcase_and_drop_none(self): user = SecureSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - username=test_user_data["username"], - avatar=test_user_data["avatar"] - ) - - base64_str = user.as_json_base64() - - # Verify it's valid base64 - decoded_bytes = base64.b64decode(base64_str) - decoded_str = decoded_bytes.decode('utf-8') - parsed = json.loads(decoded_str) - - assert parsed["user_id"] == test_user_data["user_id"] - assert parsed["email"] == test_user_data["email"] + id="1", + email="a@b.com", + username="u", + avatar="https://x/y.png", + display_name="Display Name", + website_url="https://site", + group_ids=["g1", "g2"], + is_admin=True, + is_moderator=False, + opted_in_notifications=True, + ) + parsed = json.loads(user.toJSON()) + + assert parsed["displayName"] == "Display Name" + assert parsed["websiteUrl"] == "https://site" + assert parsed["groupIds"] == ["g1", "g2"] + assert parsed["isAdmin"] is True + assert parsed["isModerator"] is False # False is kept, only None is dropped + assert parsed["optedInNotifications"] is True + # snake_case keys must never leak onto the wire + assert "display_name" not in parsed + assert "website_url" not in parsed + assert "group_ids" not in parsed + + def test_none_optionals_are_omitted(self): + user = SecureSSOUserData(id="1", email="a@b.com", username="u") + parsed = json.loads(user.toJSON()) + assert set(parsed.keys()) == {"id", "email", "username"} class TestSimpleSSOUserData: """Test SimpleSSOUserData class.""" - def test_create_simple_user_data(self, test_user_data): - """Test creating SimpleSSOUserData instance.""" - user = SimpleSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - avatar=test_user_data["avatar"] - ) + def test_includes_username(self): + """Simple SSO's simpleSSO object is keyed on username.""" + user = SimpleSSOUserData(username="tester", email="test@example.com") + parsed = json.loads(user.toJSON()) - assert user.user_id == test_user_data["user_id"] - assert user.email == test_user_data["email"] - assert user.avatar == test_user_data["avatar"] + assert parsed["username"] == "tester" + assert parsed["email"] == "test@example.com" + assert "user_id" not in parsed - def test_to_json(self, test_user_data): - """Test JSON serialization.""" + def test_optional_fields(self): user = SimpleSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - avatar=test_user_data["avatar"] + username="u", + email="a@b.com", + avatar="https://x/y.png", + website_url="https://site", ) - - json_str = user.toJSON() - parsed = json.loads(json_str) - - assert parsed["user_id"] == test_user_data["user_id"] - assert parsed["email"] == test_user_data["email"] - assert parsed["avatar"] == test_user_data["avatar"] + parsed = json.loads(user.toJSON()) + assert parsed["avatar"] == "https://x/y.png" + assert parsed["websiteUrl"] == "https://site" class TestSecureSSOPayload: - """Test SecureSSOPayload class.""" - - def test_create_payload(self): - """Test creating SecureSSOPayload instance.""" - timestamp = int(time.time()) - user_data = "test_data_base64" - hash_value = "test_hash" + """Test SecureSSOPayload class (camelCase wire format).""" - payload = SecureSSOPayload(user_data, hash_value, timestamp) + def test_to_widget_dict_is_camelcase(self): + payload = SecureSSOPayload("theb64", "thehash", 1700000000000) + d = payload.to_widget_dict() - assert payload.user_data_json_base64 == user_data - assert payload.verification_hash == hash_value - assert payload.timestamp == timestamp + assert d["userDataJSONBase64"] == "theb64" + assert d["verificationHash"] == "thehash" + assert d["timestamp"] == 1700000000000 + assert "user_data_json_base64" not in d + assert "verification_hash" not in d - def test_to_json(self): - """Test JSON serialization.""" - timestamp = int(time.time()) - payload = SecureSSOPayload("user_data", "hash_value", timestamp) + def test_to_json_is_camelcase(self): + payload = SecureSSOPayload("theb64", "thehash", 1700000000000) + parsed = json.loads(payload.toJSON()) + assert parsed["userDataJSONBase64"] == "theb64" + assert parsed["verificationHash"] == "thehash" + assert parsed["timestamp"] == 1700000000000 - json_str = payload.toJSON() - parsed = json.loads(json_str) - - assert parsed["user_data_json_base64"] == "user_data" - assert parsed["verification_hash"] == "hash_value" - assert parsed["timestamp"] == timestamp + def test_login_logout_urls_included_when_set(self): + payload = SecureSSOPayload( + "theb64", "thehash", 1700000000000, login_url="/login", logout_url="/logout" + ) + d = payload.to_widget_dict() + assert d["loginURL"] == "/login" + assert d["logoutURL"] == "/logout" class TestFastCommentsSSO: """Test FastCommentsSSO class.""" - def test_create_secure_sso(self, api_key, test_user_data): - """Test creating secure SSO using factory method.""" - user = SecureSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - username=test_user_data["username"], - avatar=test_user_data["avatar"] - ) + def test_new_secure_uses_millisecond_timestamp(self): + """Timestamp must be epoch ms (~1.7e12), not seconds (~1.7e9).""" + user = SecureSSOUserData(id="1", email="a@b.com", username="u") + before = int(time.time() * 1000) + sso = FastCommentsSSO.new_secure(PARITY_SECRET, user) + after = int(time.time() * 1000) - sso = FastCommentsSSO.new_secure(api_key, user) + ts = sso.secure_sso_payload.timestamp + assert ts >= 10 ** 12 # milliseconds, not seconds + assert before <= ts <= after - assert sso is not None - assert sso.secure_sso_payload is not None - assert sso.simple_sso_user_data is None + def test_secure_token_is_camelcase_and_verifies(self): + user = SecureSSOUserData(id="user-123", email="a@b.com", username="u") + sso = FastCommentsSSO.new_secure(PARITY_SECRET, user) + parsed = json.loads(sso.create_token()) - def test_secure_sso_token_creation(self, api_key, test_user_data): - """Test that secure SSO creates a valid token.""" - user = SecureSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - username=test_user_data["username"], - avatar=test_user_data["avatar"] - ) - - sso = FastCommentsSSO.new_secure(api_key, user) - token = sso.create_token() - - # Parse the token - parsed = json.loads(token) - - assert "user_data_json_base64" in parsed - assert "verification_hash" in parsed + assert "userDataJSONBase64" in parsed + assert "verificationHash" in parsed assert "timestamp" in parsed - # Verify the base64 data decodes to the original user data - decoded = base64.b64decode(parsed["user_data_json_base64"]) - user_data = json.loads(decoded) - assert user_data["user_id"] == test_user_data["user_id"] - - def test_create_simple_sso(self, test_user_data): - """Test creating simple SSO using factory method.""" - user = SimpleSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - avatar=test_user_data["avatar"] + # The hash must verify against the exact base64 that is sent. + expected = create_verification_hash( + PARITY_SECRET, parsed["timestamp"], parsed["userDataJSONBase64"] ) + assert parsed["verificationHash"] == expected - sso = FastCommentsSSO.new_simple(user) - - assert sso is not None - assert sso.simple_sso_user_data is not None - assert sso.secure_sso_payload is None - - def test_simple_sso_token_creation(self, test_user_data): - """Test that simple SSO creates a valid token.""" - user = SimpleSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - avatar=test_user_data["avatar"] - ) + decoded = json.loads(base64.b64decode(parsed["userDataJSONBase64"])) + assert decoded["id"] == "user-123" + def test_new_simple_token_includes_username(self): + user = SimpleSSOUserData(username="tester", email="a@b.com") sso = FastCommentsSSO.new_simple(user) - token = sso.create_token() - - # Parse the token - parsed = json.loads(token) - - assert parsed["user_id"] == test_user_data["user_id"] - assert parsed["email"] == test_user_data["email"] - assert parsed["avatar"] == test_user_data["avatar"] - - def test_secure_sso_with_urls(self, api_key, test_user_data): - """Test creating secure SSO with login/logout URLs.""" - user = SecureSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - username=test_user_data["username"], - avatar=test_user_data["avatar"] - ) - - timestamp = int(time.time()) - user_data_str = user.as_json_base64() - hash_value = create_verification_hash(api_key, timestamp, user_data_str) - payload = SecureSSOPayload(user_data_str, hash_value, timestamp) - - sso = FastCommentsSSO.new_secure_with_urls( - payload, - "/login", - "/logout" - ) + parsed = json.loads(sso.create_token()) + assert parsed["username"] == "tester" + assert parsed["email"] == "a@b.com" + def test_secure_sso_with_urls(self): + user = SecureSSOUserData(id="1", email="a@b.com", username="u") + sso = FastCommentsSSO.new_secure(PARITY_SECRET, user, login_url="/login", logout_url="/logout") assert sso.login_url == "/login" assert sso.logout_url == "/logout" + assert sso.secure_sso_payload.to_widget_dict()["loginURL"] == "/login" def test_no_data_raises_error(self): - """Test that SSO with no data raises an error.""" sso = FastCommentsSSO(None, None) - with pytest.raises(ValueError, match="No user data provided"): sso.create_token() - def test_token_caching(self, test_user_data): - """Test that tokens are cached.""" - user = SimpleSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - avatar=test_user_data["avatar"] - ) - + def test_token_caching(self): + user = SimpleSSOUserData(username="u", email="a@b.com") sso = FastCommentsSSO.new_simple(user) - token1 = sso.prepare_to_send() - token2 = sso.prepare_to_send() - - # Should return the same cached token - assert token1 == token2 - - def test_reset_token(self, test_user_data): - """Test that reset_token clears the cache.""" - user = SimpleSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - avatar=test_user_data["avatar"] - ) + assert sso.prepare_to_send() == sso.prepare_to_send() + def test_reset_token(self): + user = SimpleSSOUserData(username="u", email="a@b.com") sso = FastCommentsSSO.new_simple(user) - token1 = sso.prepare_to_send() + sso.prepare_to_send() sso.reset_token() - - # After reset, cached_token should be None assert sso.cached_token is None - def test_set_secure_sso_payload(self, api_key, test_user_data): - """Test changing from simple to secure SSO.""" - simple_user = SimpleSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - avatar=test_user_data["avatar"] - ) - - sso = FastCommentsSSO.new_simple(simple_user) - - # Now switch to secure - secure_user = SecureSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - username=test_user_data["username"], - avatar=test_user_data["avatar"] - ) - timestamp = int(time.time()) - user_data_str = secure_user.as_json_base64() - hash_value = create_verification_hash(api_key, timestamp, user_data_str) - payload = SecureSSOPayload(user_data_str, hash_value, timestamp) - + def test_set_secure_sso_payload(self): + sso = FastCommentsSSO.new_simple(SimpleSSOUserData(username="u", email="a@b.com")) + secure_user = SecureSSOUserData(id="1", email="a@b.com", username="u") + payload = FastCommentsSSO.new_secure(PARITY_SECRET, secure_user).secure_sso_payload sso.set_secure_sso_payload(payload) - assert sso.secure_sso_payload is not None assert sso.simple_sso_user_data is None - def test_set_simple_sso_user_data(self, api_key, test_user_data): - """Test changing from secure to simple SSO.""" - secure_user = SecureSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - username=test_user_data["username"], - avatar=test_user_data["avatar"] - ) - - sso = FastCommentsSSO.new_secure(api_key, secure_user) - - # Now switch to simple - simple_user = SimpleSSOUserData( - user_id=test_user_data["user_id"], - email=test_user_data["email"], - avatar=test_user_data["avatar"] - ) - - sso.set_simple_sso_user_data(simple_user) - + def test_set_simple_sso_user_data(self): + secure_user = SecureSSOUserData(id="1", email="a@b.com", username="u") + sso = FastCommentsSSO.new_secure(PARITY_SECRET, secure_user) + sso.set_simple_sso_user_data(SimpleSSOUserData(username="u", email="a@b.com")) assert sso.simple_sso_user_data is not None assert sso.secure_sso_payload is None diff --git a/tests/test_sso_integration.py b/tests/test_sso_integration.py index 9a5c2e2..47bc8a4 100644 --- a/tests/test_sso_integration.py +++ b/tests/test_sso_integration.py @@ -5,7 +5,7 @@ import pytest from sso import FastCommentsSSO, SecureSSOUserData, SimpleSSOUserData -from tests.env import API_KEY, TENANT_ID, BASE_URL +from tests.env import API_KEY, TENANT_ID, BASE_URL, HAS_CREDENTIALS # Import the client API @@ -25,7 +25,13 @@ HAS_CLIENT = True except ImportError as e: HAS_CLIENT = False - pytestmark = pytest.mark.skip(reason=f"Client not available: {e}. Run update.sh first.") + +# These tests hit the live API; skip unless the client is generated AND real +# credentials are present in the environment. +pytestmark = pytest.mark.skipif( + not HAS_CLIENT or not HAS_CREDENTIALS, + reason="Requires the generated client and real FASTCOMMENTS_API_KEY/TENANT_ID.", +) @pytest.fixture @@ -47,7 +53,7 @@ def default_api(): """Create DefaultApi instance with API key authentication.""" config = Configuration() config.host = BASE_URL - config.api_key = {"ApiKeyAuth": API_KEY} + config.api_key = {"api_key": API_KEY} client = ApiClient(configuration=config) return DefaultApi(client) @@ -57,7 +63,7 @@ def mock_secure_user(): """Create a mock secure SSO user with unique identifiers.""" timestamp = int(time.time() * 1000) # milliseconds return SecureSSOUserData( - user_id=f"test-user-{timestamp}", + id=f"test-user-{timestamp}", email=f"test-{timestamp}@example.com", username=f"testuser{timestamp}", avatar="https://example.com/avatar.jpg" @@ -69,7 +75,7 @@ def mock_simple_user(): """Create a mock simple SSO user with unique identifiers.""" timestamp = int(time.time() * 1000) # milliseconds return SimpleSSOUserData( - user_id=f"simple-user-{timestamp}", + username=f"simple-user-{timestamp}", email=f"simple-{timestamp}@example.com", avatar="https://example.com/simple-avatar.jpg" ) @@ -118,7 +124,7 @@ def test_get_comments_with_default_api(self, default_api, mock_secure_user): TENANT_ID, GetCommentsOptions( url_id="sdk-test-page-secure-admin", - context_user_id=mock_secure_user.user_id + context_user_id=mock_secure_user.id ) ) @@ -126,7 +132,10 @@ def test_get_comments_with_default_api(self, default_api, mock_secure_user): except Exception as error: if hasattr(error, "status"): - assert error.status in [404, 401, 403], f"Unexpected error status: {error.status}" + # 422: the synthetic context_user_id is not a real user + # (invalid-user). Reaching that validation confirms the API key + # authenticated successfully. + assert error.status in [404, 401, 403, 422], f"Unexpected error status: {error.status}" else: pytest.skip(f"API method signature may need updating: {str(error)}") @@ -205,7 +214,7 @@ def test_create_comment_with_simple_sso(self, public_api, mock_simple_user): { "comment": "Test comment with simple SSO from Python SDK", "date": timestamp, - "commenterName": mock_simple_user.user_id, + "commenterName": mock_simple_user.username, "commenterEmail": mock_simple_user.email, "url": "https://example.com/test-page", "urlId": "sdk-test-page-simple-comment"