Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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"
)

Expand All @@ -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 ...`
Expand Down Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 29 additions & 6 deletions sso/fastcomments_sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
40 changes: 33 additions & 7 deletions sso/secure_sso_payload.py
Original file line number Diff line number Diff line change
@@ -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)
def toJSON(self) -> str:
return json.dumps(self.to_widget_dict(), separators=(",", ":"))
81 changes: 66 additions & 15 deletions sso/secure_sso_user_data.py
Original file line number Diff line number Diff line change
@@ -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")
return base64.b64encode(self.toJSON().encode("utf-8")).decode("utf-8")
49 changes: 40 additions & 9 deletions sso/simple_sso_user_data.py
Original file line number Diff line number Diff line change
@@ -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)
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=(",", ":"))
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 8 additions & 3 deletions tests/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Loading
Loading