Skip to content

Commit 823ad2e

Browse files
committed
Fix SSO wire format: id key, ms timestamp, camelCase payload
The hand-written sso module produced tokens the FastComments server rejects (verified against server util/sso-utils.ts decryptSSOPayload): - SecureSSOUserData serialized `user_id` instead of `id`, so tokens failed with missing-user-id - FastCommentsSSO.new_secure used epoch seconds instead of milliseconds - SecureSSOPayload emitted snake_case keys (user_data_json_base64) instead of the widget/server format userDataJSONBase64 / verificationHash - SimpleSSOUserData omitted username entirely Also: - Add the optional Secure SSO fields (avatar, displayName, websiteUrl, groupIds, isAdmin, isModerator, ...), null-stripping, and login/logout URLs; add SecureSSOPayload.to_widget_dict() for embedding in a widget config - Move the generated client's runtime deps (pydantic, urllib3, ...) into a `client` extra so the base install is pure-stdlib SSO - Fix README examples (id kwarg, api_key auth key, host without /api) - Rewrite the unit tests to assert the real wire format and add a pinned cross-SDK parity vector; make unit tests run offline and gate the integration tests on real credentials
1 parent e4e4b8b commit 823ad2e

10 files changed

Lines changed: 380 additions & 328 deletions

README.md

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ from client.models import CreateAPISSOUserData
4242

4343
# Create and configure the API client
4444
config = Configuration()
45-
config.host = "https://fastcomments.com/api"
45+
config.host = "https://fastcomments.com"
4646

4747
# REQUIRED: Set your API key (get this from your FastComments dashboard)
48-
config.api_key = {"ApiKeyAuth": "YOUR_API_KEY_HERE"}
48+
config.api_key = {"api_key": "YOUR_API_KEY_HERE"}
4949

5050
# Create the API instance with the configured client
5151
api_client = ApiClient(configuration=config)
@@ -78,7 +78,7 @@ Public endpoints don't require authentication:
7878
from client import ApiClient, Configuration, PublicApi
7979

8080
config = Configuration()
81-
config.host = "https://fastcomments.com/api"
81+
config.host = "https://fastcomments.com"
8282

8383
api_client = ApiClient(configuration=config)
8484
public_api = PublicApi(api_client)
@@ -99,7 +99,7 @@ from client import ApiClient, Configuration, ModerationApi
9999
from client.api.moderation_api import GetCountOptions
100100

101101
config = Configuration()
102-
config.host = "https://fastcomments.com/api"
102+
config.host = "https://fastcomments.com"
103103

104104
api_client = ApiClient(configuration=config)
105105
moderation_api = ModerationApi(api_client)
@@ -119,21 +119,18 @@ The SDK includes utilities for generating secure SSO tokens:
119119
```python
120120
from sso import FastCommentsSSO, SecureSSOUserData
121121

122-
# Create user data
122+
# Create user data (id, email, and username are required)
123123
user_data = SecureSSOUserData(
124-
user_id="user-123",
124+
id="user-123",
125125
email="user@example.com",
126126
username="johndoe",
127127
avatar="https://example.com/avatar.jpg"
128128
)
129129

130-
# Create SSO instance with your API secret
131-
sso = FastCommentsSSO.new_secure(
132-
api_secret="YOUR_API_SECRET",
133-
user_data=user_data
134-
)
130+
# Sign it with your API secret (HMAC-SHA256)
131+
sso = FastCommentsSSO.new_secure("YOUR_API_SECRET", user_data)
135132

136-
# Generate the SSO token
133+
# Generate the SSO token to pass to the widget or an API call
137134
sso_token = sso.create_token()
138135

139136
# Use this token in your frontend or pass to API calls
@@ -146,7 +143,7 @@ For simple SSO (less secure, for testing):
146143
from sso import FastCommentsSSO, SimpleSSOUserData
147144

148145
user_data = SimpleSSOUserData(
149-
user_id="user-123",
146+
username="johndoe",
150147
email="user@example.com"
151148
)
152149

@@ -156,7 +153,7 @@ sso_token = sso.create_token()
156153

157154
### Common Issues
158155

159-
1. **401 "missing-api-key" error**: Make sure you set `config.api_key = {"ApiKeyAuth": "YOUR_KEY"}` before creating the DefaultApi instance.
156+
1. **401 "missing-api-key" error**: Make sure you set `config.api_key = {"api_key": "YOUR_KEY"}` before creating the DefaultApi instance.
160157
2. **Wrong API class**: Use `DefaultApi` for server-side authenticated requests, `PublicApi` for client-side/public requests, and `ModerationApi` for moderator dashboard requests.
161158
3. **Import errors**: Make sure you're importing from the correct module:
162159
- 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
198195
## Requirements
199196

200197
- Python >= 3.8
201-
- urllib3 >= 1.25.3
202-
- python-dateutil >= 2.8.2
203-
- pydantic >= 2.0.0
204-
- typing-extensions >= 4.0.0
198+
199+
The base install is pure-stdlib and provides the SSO utilities. The generated
200+
API client (`DefaultApi`/`PublicApi`/`ModerationApi`) needs the `client` extra,
201+
which pulls in `urllib3 >= 1.25.3`, `python-dateutil >= 2.8.2`,
202+
`pydantic >= 2.0.0`, and `typing-extensions >= 4.0.0`:
203+
204+
```bash
205+
pip install "fastcomments[client] @ git+https://github.com/fastcomments/fastcomments-python.git@v3.0.0"
206+
```
205207

206208
## License
207209

pyproject.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,20 @@ classifiers = [
2222
"Programming Language :: Python :: 3.11",
2323
"Programming Language :: Python :: 3.12",
2424
]
25-
dependencies = [
25+
# Base install is the pure-stdlib SSO module (no third-party deps). The
26+
# generated OpenAPI client needs the heavier stack below; install it with the
27+
# `client` extra: pip install "fastcomments[client] @ git+..."
28+
dependencies = []
29+
30+
[project.optional-dependencies]
31+
client = [
2632
"urllib3>=1.25.3",
2733
"python-dateutil>=2.8.2",
2834
"pydantic>=2.0.0",
2935
"typing-extensions>=4.0.0",
3036
]
31-
32-
[project.optional-dependencies]
3337
dev = [
38+
"fastcomments[client]",
3439
"pytest>=7.0.0",
3540
"pytest-cov>=4.0.0",
3641
"pytest-asyncio>=0.21.0",

sso/fastcomments_sso.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,41 @@ def __init__(self, secure_sso_payload, simple_sso_user_data, login_url: str | No
1515
self.cached_token = None
1616

1717
@classmethod
18-
def new_secure(cls, api_key: str, secure_sso_user_data: SecureSSOUserData):
19-
timestamp = int(time.time())
18+
def new_secure(
19+
cls,
20+
api_key: str,
21+
secure_sso_user_data: SecureSSOUserData,
22+
login_url: str | None = None,
23+
logout_url: str | None = None,
24+
):
25+
# Epoch MILLISECONDS. The server rejects payloads more than two days old
26+
# and hashes this exact value, so seconds would fail verification.
27+
timestamp = int(time.time() * 1000)
2028

2129
user_data_str = secure_sso_user_data.as_json_base64()
2230
hash = create_verification_hash(api_key, timestamp, user_data_str)
2331

24-
payload = SecureSSOPayload(user_data_str, hash, timestamp)
25-
return cls(secure_sso_payload = payload, simple_sso_user_data = None)
32+
payload = SecureSSOPayload(user_data_str, hash, timestamp, login_url, logout_url)
33+
return cls(
34+
secure_sso_payload=payload,
35+
simple_sso_user_data=None,
36+
login_url=login_url,
37+
logout_url=logout_url,
38+
)
2639

2740
@classmethod
28-
def new_simple(cls, simple_sso_user_data: SimpleSSOUserData):
29-
return cls(secure_sso_payload = None, simple_sso_user_data = simple_sso_user_data)
41+
def new_simple(
42+
cls,
43+
simple_sso_user_data: SimpleSSOUserData,
44+
login_url: str | None = None,
45+
logout_url: str | None = None,
46+
):
47+
return cls(
48+
secure_sso_payload=None,
49+
simple_sso_user_data=simple_sso_user_data,
50+
login_url=login_url,
51+
logout_url=logout_url,
52+
)
3053

3154
@classmethod
3255
def new_secure_with_urls(

sso/secure_sso_payload.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,40 @@
11
import json
22

3+
34
class SecureSSOPayload:
4-
def __init__(self, user_data_json_base64: str, verification_hash: str, timestamp: int):
5+
"""A signed Secure SSO payload in the FastComments wire format.
6+
7+
On the wire the keys are camelCase (`userDataJSONBase64`,
8+
`verificationHash`, `timestamp`) with `timestamp` in epoch milliseconds;
9+
`loginURL`/`logoutURL` are included only when set.
10+
"""
11+
12+
def __init__(
13+
self,
14+
user_data_json_base64: str,
15+
verification_hash: str,
16+
timestamp: int,
17+
login_url: str = None,
18+
logout_url: str = None,
19+
):
520
self.user_data_json_base64 = user_data_json_base64
621
self.verification_hash = verification_hash
722
self.timestamp = timestamp
23+
self.login_url = login_url
24+
self.logout_url = logout_url
25+
26+
def to_widget_dict(self) -> dict:
27+
"""The object the widget expects at `config.sso`."""
28+
result = {
29+
"userDataJSONBase64": self.user_data_json_base64,
30+
"verificationHash": self.verification_hash,
31+
"timestamp": self.timestamp,
32+
}
33+
if self.login_url is not None:
34+
result["loginURL"] = self.login_url
35+
if self.logout_url is not None:
36+
result["logoutURL"] = self.logout_url
37+
return result
838

9-
def toJSON(self):
10-
return json.dumps(
11-
self,
12-
default=lambda o: o.__dict__,
13-
sort_keys=True,
14-
indent=4)
39+
def toJSON(self) -> str:
40+
return json.dumps(self.to_widget_dict(), separators=(",", ":"))

sso/secure_sso_user_data.py

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,74 @@
11
import json
22
import base64
33

4+
45
class SecureSSOUserData:
5-
def __init__(self, user_id: str, email: str, username: str, avatar: str):
6-
self.user_id = user_id
6+
"""User identity for Secure SSO.
7+
8+
Serializes to the exact JSON the FastComments server validates: keyed on
9+
`id` (required), compact, camelCase optional fields, with unset optionals
10+
omitted. `id` is always stringified.
11+
"""
12+
13+
# snake_case constructor arg -> camelCase wire key, in serialization order.
14+
_FIELDS = (
15+
("id", "id"),
16+
("email", "email"),
17+
("username", "username"),
18+
("avatar", "avatar"),
19+
("display_name", "displayName"),
20+
("display_label", "displayLabel"),
21+
("website_url", "websiteUrl"),
22+
("group_ids", "groupIds"),
23+
("is_admin", "isAdmin"),
24+
("is_moderator", "isModerator"),
25+
("opted_in_notifications", "optedInNotifications"),
26+
("is_profile_activity_private", "isProfileActivityPrivate"),
27+
)
28+
29+
def __init__(
30+
self,
31+
id=None,
32+
email=None,
33+
username=None,
34+
avatar=None,
35+
display_name=None,
36+
display_label=None,
37+
website_url=None,
38+
group_ids=None,
39+
is_admin=None,
40+
is_moderator=None,
41+
opted_in_notifications=None,
42+
is_profile_activity_private=None,
43+
**extra,
44+
):
45+
self.id = None if id is None else str(id)
746
self.email = email
847
self.username = username
948
self.avatar = avatar
10-
11-
def toJSON(self):
12-
return json.dumps(
13-
self,
14-
default=lambda o: o.__dict__,
15-
sort_keys=True,
16-
indent=4)
17-
49+
self.display_name = display_name
50+
self.display_label = display_label
51+
self.website_url = website_url
52+
self.group_ids = group_ids
53+
self.is_admin = is_admin
54+
self.is_moderator = is_moderator
55+
self.opted_in_notifications = opted_in_notifications
56+
self.is_profile_activity_private = is_profile_activity_private
57+
# Escape hatch for widget SSO fields not modeled above; assumed to be
58+
# already wire-ready (camelCase) keys. None values are dropped.
59+
self.extra = {k: v for k, v in extra.items() if v is not None}
60+
61+
def to_dict(self) -> dict:
62+
result = {}
63+
for attr, wire_key in self._FIELDS:
64+
value = getattr(self, attr)
65+
if value is not None:
66+
result[wire_key] = value
67+
result.update(self.extra)
68+
return result
69+
70+
def toJSON(self) -> str:
71+
return json.dumps(self.to_dict(), separators=(",", ":"))
72+
1873
def as_json_base64(self) -> str:
19-
json_str = self.toJSON()
20-
json_bytes = json_str.encode("utf-8")
21-
22-
result = base64.b64encode(json_bytes)
23-
return result.decode("utf-8")
74+
return base64.b64encode(self.toJSON().encode("utf-8")).decode("utf-8")

sso/simple_sso_user_data.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
11
import json
22

3+
34
class SimpleSSOUserData:
4-
def __init__(self, user_id: str, email: str, avatar: str):
5-
self.user_id = user_id
5+
"""User identity for Simple (unsigned) SSO.
6+
7+
The widget's `simpleSSO` object is keyed on `username`; unset optionals are
8+
omitted. This is less secure than Secure SSO and carries no signature.
9+
"""
10+
11+
_FIELDS = (
12+
("username", "username"),
13+
("email", "email"),
14+
("avatar", "avatar"),
15+
("website_url", "websiteUrl"),
16+
("display_name", "displayName"),
17+
)
18+
19+
def __init__(
20+
self,
21+
username=None,
22+
email=None,
23+
avatar=None,
24+
website_url=None,
25+
display_name=None,
26+
**extra,
27+
):
28+
self.username = username
629
self.email = email
730
self.avatar = avatar
8-
9-
def toJSON(self):
10-
return json.dumps(
11-
self,
12-
default=lambda o: o.__dict__,
13-
sort_keys=True,
14-
indent=4)
31+
self.website_url = website_url
32+
self.display_name = display_name
33+
self.extra = {k: v for k, v in extra.items() if v is not None}
34+
35+
def to_dict(self) -> dict:
36+
result = {}
37+
for attr, wire_key in self._FIELDS:
38+
value = getattr(self, attr)
39+
if value is not None:
40+
result[wire_key] = value
41+
result.update(self.extra)
42+
return result
43+
44+
def toJSON(self) -> str:
45+
return json.dumps(self.to_dict(), separators=(",", ":"))

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def base_url():
3232
def test_user_data():
3333
"""Provide sample user data for SSO tests."""
3434
return {
35-
"user_id": "test-user-123",
35+
"id": "test-user-123",
3636
"email": "test@example.com",
3737
"username": "testuser",
3838
"avatar": "https://example.com/avatar.jpg"

tests/env.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ def get_optional_env_var(name: str, default_value: str) -> str:
2020
return os.getenv(name, default_value)
2121

2222

23-
# Test environment configuration
24-
API_KEY = get_required_env_var("FASTCOMMENTS_API_KEY")
25-
TENANT_ID = get_required_env_var("FASTCOMMENTS_TENANT_ID")
23+
# Test environment configuration.
24+
# Unit tests (SSO signing) run fully offline, so credentials are optional here;
25+
# integration tests skip themselves when real credentials are absent.
26+
API_KEY = get_optional_env_var("FASTCOMMENTS_API_KEY", "test-api-secret")
27+
TENANT_ID = get_optional_env_var("FASTCOMMENTS_TENANT_ID", "demo")
2628
BASE_URL = get_optional_env_var("FASTCOMMENTS_BASE_URL", "https://fastcomments.com")
29+
30+
# True only when real credentials were supplied (used to gate integration tests).
31+
HAS_CREDENTIALS = bool(os.getenv("FASTCOMMENTS_API_KEY") and os.getenv("FASTCOMMENTS_TENANT_ID"))

0 commit comments

Comments
 (0)