Enable password self-registration and keep SSO off (#81)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 9s
Deploy Beta / docker (push) Successful in 31s
Deploy Beta / deploy-beta (push) Successful in 11m48s

## Summary
- Closes #80.
- Password self-registration is on by default (`ENABLE_ACCOUNT_REGISTRATION`). `/user/create/` issues tokens and `needs_checkout`, and the web sign-up page already redirects into Stripe Checkout.
- Browser SSO stays off (`ENABLE_SSO`, default false). Live Google and Microsoft client ids stay available for Drive linking, but public settings no longer advertise them, and login/signup OAuth start and callback are rejected.

Stripe path checked before this change: Checkout builds a subscription session (Founders at $10/month, or `STRIPE_PRICE_ID`), stores a draft invoice, and signed `checkout.session.completed` / `invoice.paid` webhooks grant the plan. Checkout, webhook, portal, registration, and OAuth tests passed.

## Deploy note
If `chat_backend_prod.env` or `chat_backend_beta.env` on the control node still sets `ENABLE_ACCOUNT_REGISTRATION=false`, that line overrides this default. Set it to `true` or remove it, and leave `ENABLE_SSO` unset or `false`.

## Test plan
- [ ] `GET /api/public/settings/` returns `enable_account_registration: true` and `oauth.google` / `oauth.microsoft` false.
- [ ] Sign-up form creates an account and redirects to Stripe Checkout. No Google or Microsoft button.
- [ ] Checkout success grants the Founders plan after the webhook.
- [ ] `/api/auth/oauth/google/start/?intent=signup` returns 403. Drive connect still starts.
- [ ] Setting `ENABLE_ACCOUNT_REGISTRATION=false` closes `/user/create/` again.

Reviewed-on: #81
This commit was merged in pull request #81.
This commit is contained in:
westfarn committed 2026-10-20 04:44:47 -07:00
1 parent 4fc2b87bb4
commit 32f57c2376
9 files changed
+90 -14

No files matched your search

+5 -2
View File
@@ -39,8 +39,11 @@ EMAIL_USE_TLS=true
# Captcha (optional local) # Captcha (optional local)
CAPTCHA_SECRET_KEY= CAPTCHA_SECRET_KEY=
# Self-serve sign-up (default false — set true to allow /user/create/) # Password self-serve sign-up (default true). Set false to close /user/create/.
ENABLE_ACCOUNT_REGISTRATION=false ENABLE_ACCOUNT_REGISTRATION=true
# Browser SSO. Leave false until Google / Microsoft sign-in should be public.
# Client ids below can still be set for Drive linking.
ENABLE_SSO=false
# OAuth SSO — Google / Microsoft (#24). Leave blank to hide SSO buttons. # OAuth SSO — Google / Microsoft (#24). Leave blank to hide SSO buttons.
# Redirect URIs (register in each IdP console): # Redirect URIs (register in each IdP console):
+7 -3
View File
@@ -59,9 +59,13 @@ EMAIL_USE_TLS=true
# Captcha # Captcha
CAPTCHA_SECRET_KEY=replace-with-captcha-secret CAPTCHA_SECRET_KEY=replace-with-captcha-secret
# Self-serve account registration (sign-up). Keep false until ready to open # Password self-serve sign-up. Default true. Set false in
# public registration; set true in chat_backend_prod.env / chat_backend_beta.env. # chat_backend_prod.env / chat_backend_beta.env to close public registration.
ENABLE_ACCOUNT_REGISTRATION=false # An explicit false in that secret overrides the code default.
ENABLE_ACCOUNT_REGISTRATION=true
# Browser SSO stays off. OAuth client ids below are for Drive linking until
# this is set true.
ENABLE_SSO=false
# OAuth SSO — Google / Microsoft (#24). Never commit real secrets. # OAuth SSO — Google / Microsoft (#24). Never commit real secrets.
# Register redirect URIs: # Register redirect URIs:
+2 -1
View File
@@ -97,7 +97,8 @@ with `COMPOSE_DATABASE_URL` if needed.
| `SEARXNG_BASE_URL` | `http://127.0.0.1:8088` | yes if using SearxNG | Self-hosted SearxNG JSON API | | `SEARXNG_BASE_URL` | `http://127.0.0.1:8088` | yes if using SearxNG | Self-hosted SearxNG JSON API |
| `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO | | `EMAIL_HOST_*` | empty | yes (prod/beta) | SMTP2GO |
| `CAPTCHA_SECRET_KEY` | empty | recommended | | | `CAPTCHA_SECRET_KEY` | empty | recommended | |
| `ENABLE_ACCOUNT_REGISTRATION` | `false` | optional | Self-serve sign-up; keep false until ready | | `ENABLE_ACCOUNT_REGISTRATION` | `true` | optional | Password self-serve sign-up. Set `false` to close it |
| `ENABLE_SSO` | `false` | optional | Google / Microsoft sign-in and sign-up. Drive linking does not need this |
| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook (`monetization` app) | | `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` / `STRIPE_WEBHOOK_SECRET` | empty | yes for billing | Stripe API + webhook (`monetization` app) |
| `APPLE_IAP_BUNDLE_ID` | `ai.hesychia.chat` | yes for iOS IAP | Must match the signed StoreKit transaction | | `APPLE_IAP_BUNDLE_ID` | `ai.hesychia.chat` | yes for iOS IAP | Must match the signed StoreKit transaction |
| `GOOGLE_PLAY_PACKAGE_NAME` | `ai.hesychia.chat` | yes for Play IAP | Android application id | | `GOOGLE_PLAY_PACKAGE_NAME` | `ai.hesychia.chat` | yes for Play IAP | Android application id |
+2 -2
View File
@@ -459,8 +459,8 @@ def resolve_user_from_profile(
upsert_identity(email_user, profile) upsert_identity(email_user, profile)
return email_user, False return email_user, False
# New account path # New account path. Password registration does not enable SSO sign-up.
allow_create = settings.ENABLE_ACCOUNT_REGISTRATION allow_create = settings.ENABLE_ACCOUNT_REGISTRATION and settings.ENABLE_SSO
if intent == "signup" and not allow_create: if intent == "signup" and not allow_create:
raise OAuthError("registration_disabled", "Account registration is disabled.") raise OAuthError("registration_disabled", "Account registration is disabled.")
if intent == "login" and not allow_create: if intent == "login" and not allow_create:
+38
View File
@@ -24,6 +24,7 @@ OAUTH_SETTINGS = {
"FRONTEND_BASE_URL": "http://frontend.test", "FRONTEND_BASE_URL": "http://frontend.test",
"OAUTH_CALLBACK_BASE_URL": "http://backend.test", "OAUTH_CALLBACK_BASE_URL": "http://backend.test",
"ENABLE_ACCOUNT_REGISTRATION": True, "ENABLE_ACCOUNT_REGISTRATION": True,
"ENABLE_SSO": True,
} }
@@ -53,6 +54,14 @@ class PublicSettingsOAuthTestCase(APITestCase):
self.assertTrue(response.data["oauth"]["google"]) self.assertTrue(response.data["oauth"]["google"])
self.assertTrue(response.data["oauth"]["microsoft"]) self.assertTrue(response.data["oauth"]["microsoft"])
@override_settings(ENABLE_SSO=False)
def test_hides_oauth_when_sso_disabled(self):
response = self.client.get(reverse("public_settings"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data["enable_account_registration"])
self.assertFalse(response.data["oauth"]["google"])
self.assertFalse(response.data["oauth"]["microsoft"])
@override_settings(GOOGLE_OAUTH_CLIENT_ID="", GOOGLE_OAUTH_CLIENT_SECRET="") @override_settings(GOOGLE_OAUTH_CLIENT_ID="", GOOGLE_OAUTH_CLIENT_SECRET="")
def test_hides_unconfigured_provider(self): def test_hides_unconfigured_provider(self):
response = self.client.get(reverse("public_settings")) response = self.client.get(reverse("public_settings"))
@@ -83,6 +92,15 @@ class OAuthStartTestCase(APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@override_settings(ENABLE_SSO=False)
def test_login_and_signup_blocked_when_sso_disabled(self):
for intent in ("login", "signup"):
response = self.client.get(
reverse("oauth_start", kwargs={"provider": "google"}),
{"intent": intent},
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False) @override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
def test_signup_intent_blocked_when_registration_disabled(self): def test_signup_intent_blocked_when_registration_disabled(self):
response = self.client.get( response = self.client.get(
@@ -180,6 +198,17 @@ class OAuthCallbackTestCase(APITestCase):
params = parse_qs(urlparse(response["Location"]).query) params = parse_qs(urlparse(response["Location"]).query)
self.assertEqual(params["error"], ["email_missing"]) self.assertEqual(params["error"], ["email_missing"])
@patch("chat_backend.views_oauth.exchange_code_for_profile")
@override_settings(ENABLE_SSO=False)
def test_callback_signup_rejected_when_sso_disabled(self, mock_exchange):
mock_exchange.return_value = _google_profile()
response = self._callback(intent="signup")
params = parse_qs(urlparse(response["Location"]).query)
self.assertEqual(params["error"], ["sso_disabled"])
self.assertEqual(CustomUser.objects.count(), 0)
mock_exchange.assert_not_called()
@patch("chat_backend.views_oauth.exchange_code_for_profile") @patch("chat_backend.views_oauth.exchange_code_for_profile")
@override_settings(ENABLE_ACCOUNT_REGISTRATION=False) @override_settings(ENABLE_ACCOUNT_REGISTRATION=False)
def test_callback_login_without_account_when_registration_off(self, mock_exchange): def test_callback_login_without_account_when_registration_off(self, mock_exchange):
@@ -256,6 +285,15 @@ class OAuthStartDriveLinkTestCase(APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(ENABLE_SSO=False)
def test_link_drive_still_starts_when_sso_disabled(self):
self.client.force_authenticate(user=self.user)
response = self.client.get(
reverse("oauth_start", kwargs={"provider": "google"}),
{"intent": "link_drive"},
)
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
def test_link_drive_authenticated_redirects_with_drive_scope_and_state(self): def test_link_drive_authenticated_redirects_with_drive_scope_and_state(self):
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
response = self.client.get( response = self.client.get(
+19 -1
View File
@@ -115,9 +115,27 @@ class PublicSettingsTestCase(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["enable_account_registration"], False) self.assertEqual(response.data["enable_account_registration"], False)
def test_registration_on_and_sso_hidden_by_default(self):
response = self.client.get(reverse("public_settings"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["enable_account_registration"], True)
self.assertEqual(response.data["oauth"], {"google": False, "microsoft": False})
class UserCreateTestCase(APITestCase): class UserCreateTestCase(APITestCase):
def test_registration_disabled_by_default(self): def test_registration_enabled_by_default(self):
response = self.client.post(
reverse("create_user"),
{"email": "new@example.com", "password": "securepass1"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertTrue(CustomUser.objects.filter(email="new@example.com").exists())
def test_registration_disabled_when_setting_off(self):
with self.settings(ENABLE_ACCOUNT_REGISTRATION=False):
response = self.client.post( response = self.client.post(
reverse("create_user"), reverse("create_user"),
{"email": "new@example.com", "password": "securepass1"}, {"email": "new@example.com", "password": "securepass1"},
+1 -1
View File
@@ -178,7 +178,7 @@ class PublicWaitlistView(APIView):
class CustomUserCreate(APIView): class CustomUserCreate(APIView):
"""Self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION (default off).""" """Password self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION."""
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
authentication_classes = () authentication_classes = ()
+10 -1
View File
@@ -102,6 +102,11 @@ class OAuthStartView(APIView):
}, },
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if intent in {"login", "signup"} and not settings.ENABLE_SSO:
return Response(
{"detail": "Single sign-on is not enabled."},
status=status.HTTP_403_FORBIDDEN,
)
if intent == "signup" and not settings.ENABLE_ACCOUNT_REGISTRATION: if intent == "signup" and not settings.ENABLE_ACCOUNT_REGISTRATION:
return Response( return Response(
{"detail": "Account registration is disabled."}, {"detail": "Account registration is disabled."},
@@ -184,6 +189,8 @@ class OAuthCallbackView(APIView):
return self._handle_drive_link_callback( return self._handle_drive_link_callback(
request, provider=provider, code=code, state_data=state_data request, provider=provider, code=code, state_data=state_data
) )
if not settings.ENABLE_SSO:
return _redirect_error("sso_disabled", "Single sign-on is not enabled.")
try: try:
redirect_uri = _callback_redirect_uri(request, provider) redirect_uri = _callback_redirect_uri(request, provider)
@@ -261,5 +268,7 @@ class OAuthCallbackView(APIView):
def oauth_public_flags() -> dict: def oauth_public_flags() -> dict:
"""Feature flags for /public/settings/.""" """Feature flags for /public/settings/. SSO buttons stay hidden until ENABLE_SSO."""
if not settings.ENABLE_SSO:
return {"google": False, "microsoft": False}
return configured_providers() return configured_providers()
+6 -3
View File
@@ -381,9 +381,12 @@ AGENT_FETCH_URL_MAX_BYTES = int(
env("AGENT_FETCH_URL_MAX_BYTES", str(2 * 1024 * 1024)) or str(2 * 1024 * 1024) env("AGENT_FETCH_URL_MAX_BYTES", str(2 * 1024 * 1024)) or str(2 * 1024 * 1024)
) )
# Self-serve account registration (sign-up page). Default off — enable via # Password self-serve sign-up. On by default (#80). Set false in the
# control-node secret (chat_backend_<env>.env) when ready for public sign-up. # control-node secret (chat_backend_<env>.env) to close public registration.
ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", False) ENABLE_ACCOUNT_REGISTRATION = env_bool("ENABLE_ACCOUNT_REGISTRATION", True)
# Browser SSO (Google / Microsoft sign-in and sign-up). Off until explicitly
# enabled. OAuth client ids may still be set for Drive linking (#47).
ENABLE_SSO = env_bool("ENABLE_SSO", False)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# OAuth SSO (Google / Microsoft) — #24 # OAuth SSO (Google / Microsoft) — #24