Reject invalid SOCIAL_TOKEN_ENCRYPTION_KEY instead of 500ing.
Deploy Beta / unit-tests (push) Successful in 13s
Deploy Beta / docker (push) Successful in 18s
Deploy Beta / deploy-beta (push) Successful in 1m39s

Placeholder Fernet values like replace-with-fernet-key now raise a clear SocialCryptoError in the portal, and prod env examples no longer ship a fake key.
This commit is contained in:
2026-08-11 07:59:36 -05:00
parent 44d300271a
commit c8dacd0d94
3 changed files with 67 additions and 14 deletions
+5 -2
View File
@@ -77,7 +77,10 @@ PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"replac
# LinkedIn Auth redirect (prod): https://YOUR_DOMAIN/portal/social/accounts/linkedin/callback/ # LinkedIn Auth redirect (prod): https://YOUR_DOMAIN/portal/social/accounts/linkedin/callback/
META_APP_ID= META_APP_ID=
META_APP_SECRET= META_APP_SECRET=
SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key # Must be a real Fernet key (44 chars). Generate:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Do NOT use a placeholder — invalid keys break LinkedIn/Meta token save.
SOCIAL_TOKEN_ENCRYPTION_KEY=
# Ollama for social drafting (reachable from app hosts) # Ollama for social drafting (reachable from app hosts)
OLLAMA_BASE_URL=http://10.0.0.128:11434 OLLAMA_BASE_URL=http://10.0.0.128:11434
@@ -116,7 +119,7 @@ GUNICORN_BIND=0.0.0.0:8000
# EMAIL_HOST_PASSWORD=replace-me # EMAIL_HOST_PASSWORD=replace-me
# SMTP2GO_SMS_API_KEY=replace-me # SMTP2GO_SMS_API_KEY=replace-me
# LOB_API_KEY=replace-me # LOB_API_KEY=replace-me
# SOCIAL_TOKEN_ENCRYPTION_KEY=replace-with-fernet-key # SOCIAL_TOKEN_ENCRYPTION_KEY= # real Fernet.generate_key() output, not a placeholder
# OLLAMA_BASE_URL=http://10.0.0.128:11434 # OLLAMA_BASE_URL=http://10.0.0.128:11434
# NOMINATIM_BASE_URL=http://10.0.0.128:8089 # NOMINATIM_BASE_URL=http://10.0.0.128:8089
# NOMINATIM_COUNTRY_CODES=us # NOMINATIM_COUNTRY_CODES=us
+48 -8
View File
@@ -1,17 +1,55 @@
"""Token encryption helpers for SocialAccount.""" """Token encryption helpers for SocialAccount / SocialAppCredentials."""
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings from django.conf import settings
# Copied from .env.prod.example / deploy templates — not valid Fernet material.
_PLACEHOLDER_KEYS = frozenset(
{
"replace-with-fernet-key",
"changeme",
"change-me",
"todo",
}
)
class SocialCryptoError(ValueError):
"""Raised when SOCIAL_TOKEN_ENCRYPTION_KEY is missing or not a Fernet key."""
def _normalize_key(raw: str | bytes | None) -> bytes | None:
if raw is None:
return None
if isinstance(raw, bytes):
text = raw.decode("utf-8", errors="ignore").strip()
else:
text = str(raw).strip()
if not text or text.lower() in _PLACEHOLDER_KEYS:
return None
return text.encode()
def _fernet() -> Fernet: def _fernet() -> Fernet:
key = settings.SOCIAL_TOKEN_ENCRYPTION_KEY key = _normalize_key(settings.SOCIAL_TOKEN_ENCRYPTION_KEY)
if not key: if key is None:
# Dev-only fallback — generate is not stable across restarts; set the env var. if settings.DEBUG:
key = Fernet.generate_key().decode() # Dev-only unstable fallback — set SOCIAL_TOKEN_ENCRYPTION_KEY for real use.
if isinstance(key, str): return Fernet(Fernet.generate_key())
key = key.encode() raise SocialCryptoError(
return Fernet(key) "SOCIAL_TOKEN_ENCRYPTION_KEY is not set. Generate one with: "
'python -c "from cryptography.fernet import Fernet; '
'print(Fernet.generate_key().decode())" '
"and add it to the beta/prod app env, then redeploy/restart."
)
try:
return Fernet(key)
except (ValueError, TypeError) as exc:
raise SocialCryptoError(
"SOCIAL_TOKEN_ENCRYPTION_KEY is invalid (must be 32 url-safe "
"base64-encoded bytes from Fernet.generate_key()). "
"Replace the placeholder in the beta/prod env and restart."
) from exc
def encrypt_tokens(plaintext: str) -> str: def encrypt_tokens(plaintext: str) -> str:
@@ -25,3 +63,5 @@ def decrypt_tokens(ciphertext: str) -> str:
return _fernet().decrypt(ciphertext.encode()).decode() return _fernet().decrypt(ciphertext.encode()).decode()
except InvalidToken as exc: except InvalidToken as exc:
raise ValueError("Unable to decrypt social tokens") from exc raise ValueError("Unable to decrypt social tokens") from exc
except SocialCryptoError:
raise
+14 -4
View File
@@ -11,7 +11,7 @@ from django.utils.safestring import mark_safe
from django.views.decorators.http import require_GET, require_http_methods, require_POST from django.views.decorators.http import require_GET, require_http_methods, require_POST
from messaging.services import parse_scheduled_for from messaging.services import parse_scheduled_for
from social.crypto import encrypt_tokens from social.crypto import SocialCryptoError, encrypt_tokens
from social.models import ( from social.models import (
Platform, Platform,
SocialAccount, SocialAccount,
@@ -143,7 +143,11 @@ def account_list(request):
) )
app.client_id = client_id app.client_id = client_id
if client_secret: if client_secret:
app.encrypted_client_secret = encrypt_tokens(client_secret) try:
app.encrypted_client_secret = encrypt_tokens(client_secret)
except SocialCryptoError as exc:
messages.error(request, str(exc))
return redirect(f"{request.path}?connect=linkedin")
elif not app.encrypted_client_secret: elif not app.encrypted_client_secret:
messages.error( messages.error(
request, request,
@@ -178,12 +182,18 @@ def account_list(request):
elif platform == Platform.LINKEDIN: elif platform == Platform.LINKEDIN:
token_blob["author_urn"] = external_id token_blob["author_urn"] = external_id
try:
encrypted = encrypt_tokens(json.dumps(token_blob))
except SocialCryptoError as exc:
messages.error(request, str(exc))
return redirect(f"{request.path}?connect={platform}")
account, created = SocialAccount.objects.update_or_create( account, created = SocialAccount.objects.update_or_create(
platform=platform, platform=platform,
external_id=external_id, external_id=external_id,
defaults={ defaults={
"label": label, "label": label,
"encrypted_tokens": encrypt_tokens(json.dumps(token_blob)), "encrypted_tokens": encrypted,
"is_active": True, "is_active": True,
"owner": request.user, "owner": request.user,
}, },
@@ -273,7 +283,7 @@ def linkedin_oauth_callback(request):
"owner": request.user, "owner": request.user,
}, },
) )
except LinkedInOAuthError as exc: except (LinkedInOAuthError, SocialCryptoError) as exc:
messages.error(request, str(exc)) messages.error(request, str(exc))
return redirect("social:account_list") return redirect("social:account_list")
except Exception: except Exception: