Skip to content

SSO - #3511

Open
YishaiGlasner wants to merge 132 commits into
masterfrom
sso
Open

SSO#3511
YishaiGlasner wants to merge 132 commits into
masterfrom
sso

Conversation

@YishaiGlasner

@YishaiGlasner YishaiGlasner commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces Sefaria's old server-rendered login/register/password-reset templates with a single React auth experience (AuthPage), and adds Google + Apple Sign-In (SSO) for web and mobile, built on django-allauth. Also includes a new sign-up funnel analytics pipeline, an account-settings UI update to reflect SSO-linked accounts, and a handful of unrelated fixes picked up along the way (SSR crash, proxy header handling, i18n).

Backend: django-allauth integration + new sso app

  • Added django-allauth (allauth, allauth.account, allauth.socialaccount, Google + Apple providers, allauth.headless) to INSTALLED_APPS, plus its authentication backend and account middleware (sefaria/settings.py).
  • New sso app (sso/adapters.py, sso/views.py, sso/urls.py):
    • SefariaAccountAdapter — ties emailusernames (username == email) into allauth, and honors a next redirect stashed in a short-lived sefaria_sso_next cookie (needed because Google's redirect-mode SSO can't carry next as a query param — the login_uri must be an exact-match registered redirect URI).
    • SefariaSocialAccountAdapter — handles first-time social sign-up (creates the Mongo UserProfile, assigns slug, imports Gravatar, registers the user in Salesforce CRM) and email-collision linking (disables the password on an existing email/password account when a Google/Apple sign-in claims the same email, making it SSO-only going forward).
    • import_gravatar extracted out of sefaria/views.py into sso/adapters.py so both the SSO and classic email registration paths share it.
    • New endpoints under sso/urls.py:
      • POST /api/auth/google/redirect — Google One Tap redirect-mode callback (delegates to allauth's LoginByTokenView)
      • POST /api/auth/google/mobile / POST /api/auth/apple/mobile — native mobile (React Native) Google/Apple sign-in; verifies the provider ID token server-side and returns SimpleJWT {access, refresh} tokens
      • POST /api/auth/apple/callback — Apple Sign-In popup mode (web)
      • POST /api/auth/login — JSON email login for the new AuthPage
      • POST /api/auth/password/reset — JSON password-reset request
    • Mobile JWT endpoints are hardened: session is flushed after issuing tokens, Apple-provided names are sanitized, and JWKS fetch failures are reported to Sentry.
  • ClearSsoNextCookieMiddleware — deletes the sefaria_sso_next cookie as soon as either SSO callback view responds (success or failure) so it can't be replayed by a later, unrelated attempt.
  • SECURE_PROXY_SSL_HEADER now set so request.is_secure() (and allauth's OAuth2 callback URLs built from request.build_absolute_uri()) correctly report https behind the TLS-terminating ingress.
  • sefaria/forms.py — registration now distinguishes "already registered via Google", "via Apple", or plain email/password when an email collision is hit.
  • sefaria/views.py:
    • CustomLoginView / CustomPasswordResetConfirmView now render the SPA shell (base.html) instead of the old dedicated templates, and CustomPasswordResetConfirmView gained a JSON POST path (submit new password, or request a resend when the link has expired) so the flow works fully client-side.
    • process_register_form simplified now that Gravatar import lives in sso/adapters.py.
  • account_settings view/template now surface social_providers (from request.user.socialaccount_set) so the account page shows "Google/Apple Sign-In" instead of an editable email field for SSO-only accounts, and the Google Drive connection copy was reworded for clarity.
  • Deleted the old, now-unused templates: registration/login.html, registration/register.html, registration/accounts.html, and the long-dead translate_campaign.html.
  • Removed the dead /accounts view.
  • LanguageSettingsMiddleware refactored to extract interface-language resolution into a shared helper (used by both the "excluded path" short-circuit and the normal path), fixing interface language on excluded paths (e.g. auth API paths) instead of hardcoding English.
  • Config/deploy: new GOOGLE_SSO_CLIENT_ID / APPLE_SSO_* settings (local_settings_example.py, Helm chart local-settings-file.yaml), django-allauth added to requirements.txt.

Frontend: new AuthPage React experience (static/js/auth/)

  • AuthPage.jsx — single state machine (view × flow) that swaps card content in place with no full page navigation, replacing the old /login, /register, and /password/reset/confirm server-rendered pages. Views: choose, email (login or register), forgot, forgot-sent, reset, reset-expired, reset-success.
  • Supporting components: ChooseView, LoginView, RegisterView, ForgotView, ResetView, ResetExpiredView, MessageView, ErrorBanner, AuthCard, Divider, LegalText, EmailInput, PasswordInput, ProviderButton, FormView.
  • useSsoSignIn.jsx — shared hook wiring up Google Sign-In (button + One Tap) and Apple Sign-In JS SDKs, exposing provider-ready flags and click triggers to the views above.
  • GoogleOneTap.jsx — renders Google's One Tap prompt globally, mounted once in ReaderApp.
  • utils.js — path/flow helpers (pathToFlow, flowToPath, nextFromPath, isAuthPath, withNext) shared between AuthPage and ReaderApp.
  • New reusable primitives moved/added under static/js/common/: Input.jsx, Captcha.jsx.
  • ReaderApp.jsx — now owns auth routing: recognizes /login, /register, and reset-confirm paths, tracks showAuth/authPath/authSource in component state and browser history, and renders AuthPage in place of the normal panel container when active. Also mounts GoogleOneTap and resumes any pending sign-up analytics attempt on mount.
  • Header.jsx — login/sign-up links now route through the in-app AuthPage (via a new AuthNavLink that calls openURL) instead of full-page navigation, and the nav-bar sign-up button carries a data-signup-source marker for funnel attribution.
  • New CSS: auth.scss/auth.css and common-component.scss/common-component.css, plus new icons (google.svg, apple.svg, eye.svg, eye-off.svg, arrow-left.svg, info-error.svg) and auth background images.
  • base.html — exposes window.SefariaAuth / passes googleClientId, appleClientId, recaptchaSiteKey, and reset-link state (authResetUid, authResetValid) down to the client.
  • templates/elements/login_method_text.html — small partial used by the updated account-settings page.

Sign-up funnel analytics

  • signupAnalytics.js / useSignUpTracking.js — new tracking layer for the sign-up funnel (method chosen, process started/ended, resuming an in-progress attempt across a redirect-based SSO round trip via resumePendingSignUpAttempt), replacing the old inline GA4 script that used to live in register.html.
  • Test coverage added: static/js/auth/tests/signupAnalytics.test.js, useSignUpTracking.test.js, utils.test.js.

i18n

  • New auth.* string keys added to static/js/sefaria/i18n/interface-context/en.json and he.json (English + Hebrew) for every AuthPage view, replacing the old templates' hand-rolled int-en/int-he spans.

Tests

  • sso/tests/adapters_test.py, sso/tests/views_test.py, sso/tests/middleware_test.py — new backend coverage for the adapters, all sso/views.py endpoints, and ClearSsoNextCookieMiddleware.
  • reader/tests/password_reset_confirm_view_test.py — coverage for the new JSON reset-confirm flow.
  • sefaria/tests/forms_test.py, sefaria/system/tests/interface_language_test.py — coverage for the updated registration-form email-collision messaging and the refactored language middleware.
  • e2e-tests/ — Playwright specs and page objects (loginPage.ts, signupPage.ts, mobileHamburgerPage.ts) updated for the new AuthPage flow; added User Menu/auth-page.spec.ts and updated mobile web/auth-flow.spec.ts.

Other fixes bundled into this branch

  • Fixed a chatbot site-wide-banner SSR crash by computing its text at render time instead of module load (static/js/SiteWideBanner.jsx / sitewidebanner).
  • Trust X-Forwarded-Proto behind the TLS-terminating proxy (see SECURE_PROXY_SSL_HEADER above).
  • Google mobile SSO now honors next instead of always redirecting home; the sefaria_sso_next cookie is sent as SameSite=None and percent-decoded correctly before use.
  • Percent-decoding fix for the sefaria_sso_next cookie value.
  • Real interface language is now sent to Salesforce on SSO sign-up (previously hardcoded).

Notable dependencies/config

  • New Python dependency: django-allauth[socialaccount].
  • New settings: GOOGLE_SSO_CLIENT_ID, APPLE_SSO_CLIENT_ID, APPLE_SSO_IOS_BUNDLE_ID, APPLE_SSO_TEAM_ID, APPLE_SSO_KEY_ID, APPLE_SSO_PRIVATE_KEY — must be populated (via env vars in deployed environments) for Google/Apple sign-in to work.

move general utils to a new file.
move forget business logic to ForgotView.
remove onProviderClick from EmailView.
remove props that comes from baseprops and unpack them in Sefaria.
move ChooseView buisness logic from AuthPage.
…ay because this div (with z index) has overridden the cursor behaviour.
@gitvelocity-reviewer

Copy link
Copy Markdown

📊 Code Quality Score: 63/100

63 × 1.0 (Extra Large ESF) = 63

Category Score Factors
🔭 Scope 16/20 New auth module with 10+ files, touches ReaderApp critical path, sefaria.js, views.py; new SSO flows; removes old templates; new CSS/icons/images
🏗️ Architecture 15/20 Clean state machine pattern, new auth component hierarchy, SSO SDK integration with overlay technique, SPA history management for auth, removes Django template auth
⚙️ Implementation 14/20 SSO SDK polling/overlay, analytics lifecycle management, CSRF+captcha integration, curried handlers, browser history state for auth flows, multi-view state machine
⚠️ Risk 12/20 Auth system changes (high risk), removes existing templates, SSO external SDK dependencies, no feature flags, StaticViewMixin removal may break context, no rollback plan documented
✅ Quality 3/15 Zero tests for significant new auth feature; good JSDoc/PropTypes documentation; well-organized SCSS; missing E2E for critical auth flows
🔒 Perf / Security 3/5 safeNext prevents open redirects, CSRF tokens used, SSO state parameter for OAuth CSRF, cookie security attributes; auth CSS loaded globally on all pages

Was this score accurate? 👍 Yes · 👎 No

Scored by GitVelocity · How are scores calculated?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the legacy Django-rendered login/register pages with a React-driven authentication experience rendered inside ReaderApp, and wires server-rendered base.html + base_props to expose SSO and reCAPTCHA configuration to the client.

Changes:

  • Removed templates/registration/login.html and templates/registration/register.html, shifting /login and /register to render base.html so React can take over.
  • Added a new client-side auth flow under static/js/auth/ (choose provider, email login/register, forgot password) and integrated it into static/js/ReaderApp.jsx with history support.
  • Added new global CSS/assets for the auth UI and a --header-height CSS var to position the auth page below the header.

Reviewed changes

Copilot reviewed 24 out of 37 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
templates/registration/register.html Deleted legacy Django register template (migrated to React auth).
templates/registration/login.html Deleted legacy Django login template (migrated to React auth).
templates/base.html Loads new auth/common-component styles globally.
static/js/sefaria/strings.js Adds Hebrew interface strings for the new auth UI.
static/js/sefaria/sefaria.js Unpacks new base props + adds small SSO redirect helpers.
static/js/ReaderApp.jsx Adds /login and /register routing to render AuthPage and store auth state in history.
static/js/common/Input.jsx New design-system input (includes password reveal + inline error).
static/js/common/Captcha.jsx New wrapper for rendering reCAPTCHA with a design-system error state.
static/js/auth/utils.js Shared auth helpers (csrf, safeNext, error formatting, readiness polling).
static/js/auth/ProviderButton.jsx Custom SSO provider button shell (Google overlay + Apple click).
static/js/auth/LegalText.jsx Terms/Privacy legal copy component for auth screens.
static/js/auth/ForgotView.jsx Forgot-password request form (client-side).
static/js/auth/ForgotSentView.jsx Forgot-password confirmation view (client-side).
static/js/auth/ErrorBanner.jsx Inline error banner with provider-action links for SSO-only accounts.
static/js/auth/EmailView.jsx Email login/register form, reCAPTCHA (register), and registration analytics hooks.
static/js/auth/Divider.jsx Divider (“or”) between SSO and email entry.
static/js/auth/ChooseView.jsx Provider-selection view + Google GIS/Apple JS SDK initialization.
static/js/auth/AuthPage.jsx Top-level auth state machine and GA4 registration funnel tracking.
static/js/auth/AuthCard.jsx Presentational auth card container (heading/sub/back + content).
static/icons/google.svg New Google icon for provider UI.
static/icons/eye.svg New “show password” icon.
static/icons/eye-off.svg New “hide password” icon.
static/icons/arrow-left.svg New back-arrow icon for auth card.
static/icons/apple.svg New Apple icon for provider UI.
static/css/header.scss Introduces $header-height + --header-height CSS variable.
static/css/header.css Compiled CSS updated for --header-height.
static/css/header.css.map Updated sourcemap for compiled header CSS.
static/css/common-component.scss New SCSS for shared auth/common components.
static/css/common-component.css Compiled CSS for shared auth/common components.
static/css/common-component.css.map Sourcemap for shared auth/common component CSS.
static/css/auth.scss New SCSS for auth page/card/provider UI layout.
static/css/auth.css Compiled CSS for auth UI.
static/css/auth.css.map Sourcemap for compiled auth CSS.
sefaria/views.py Renders base.html for login/register and adds JSON mode support for register.
reader/views.py Adds googleClientId, appleClientId, recaptchaSiteKey to base_props.
Files not reviewed (3)
  • static/css/auth.css: Generated file
  • static/css/common-component.css: Generated file
  • static/css/header.css: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread static/css/auth.scss
Comment on lines +256 to +270
.sefaria-provider-sdk-overlay {
position: absolute;
inset: 0;
z-index: 1;
opacity: 0.0001;
overflow: hidden;
pointer-events: none;

> div,
iframe {
width: 100% !important; // overrides Google SDK inline style
height: 100% !important; // overrides Google SDK inline style
margin: 0 !important; // overrides Google SDK inline style
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — this is a genuine feature-killer, not a nit. ProviderButton gives the Google button no onClick; per the comment in ChooseView.jsx, GIS exposes no programmatic trigger, so the invisible iframe overlay receiving the click is the only activation path. pointer-events appears exactly once in auth.scss (this line), and the > div, iframe rule only overrides width/height/margin — nothing re-enables it. Also note the comment directly above claims the overlay "captures clicks correctly", which this rule prevents.

Worth flagging that even with this fixed, Google sign-in still can't work: the GIS SDK script is never loaded anywhere on the branch, and /api/auth/google/callback doesn't exist. Three independent blockers.

Comment thread static/css/auth.css
Comment on lines +230 to +237
.sefaria-provider-sdk-overlay {
position: absolute;
inset: 0;
z-index: 1;
opacity: 0.0001;
overflow: hidden;
pointer-events: none;
}
Comment on lines +75 to +81
<a
className="sefaria-input-trailingLink"
href={trailingLink.href}
onClick={trailingLink.onClick}
>
{trailingLink.text}
</a>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. EmailView passes passwordTrailingLink: { text, onClick } with no href, so React renders a bare <a onClick> — not in the tab order, so keyboard users cannot reach "Forgot password?" at all.

Cleanest fix given the file already has the pattern: render a <button type="button"> when href is absent, mirroring the reveal-password toggle a few lines down (which is correctly implemented).

Comment thread static/js/ReaderApp.jsx Outdated
Comment thread static/js/ReaderApp.jsx Outdated
Comment on lines +1295 to +1301
if (path === '/login') {
this.showAuthPage('login', params.get('next') || '/');
return true;
} else if (path === '/register') {
this.showAuthPage('register', params.get('next') || '/');
return true;
}
Comment thread static/js/auth/ForgotView.jsx Outdated
Comment on lines +21 to +29
const res = await fetch('/api/auth/password/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
body: JSON.stringify({ email: emailValue }),
});
if (res.ok) { onSuccess(); }
else {
const d = await res.json().catch(() => ({}));
setError(authError(d, 'Something went wrong. Try again.'));
Comment thread static/js/auth/ChooseView.jsx Outdated
Comment on lines +54 to +62
const config = {
client_id: googleClientId,
ux_mode: useRedirect ? 'redirect' : 'popup',
};
if (useRedirect) {
config.login_uri = `${window.location.origin}/auth/google/redirect`;
} else {
config.callback = (resp) => onSSOResult('/api/auth/google/callback', { credential: resp.credential });
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on the missing routes. Adding one you didn't catch: the Google Identity Services SDK script is never loaded anywhere on this branch (no gsi/client reference in any template or JS), so window.google never appears, whenReady times out after 8s, and the button stays permanently disabled — the flow dies before it ever reaches a callback URL. Same for Apple (appleid.auth).

Comment thread static/js/auth/ChooseView.jsx Outdated
Comment on lines +90 to +111
onSSOResult('/api/auth/apple/callback', {
id_token: a.id_token, first_name: n.firstName || '', last_name: n.lastName || '', email: u.email || '',
});
};
const onFail = (ev) => {
if (ev.detail?.error !== 'popup_closed_by_user') {
setError(authError(null, 'Something went wrong. Try again.'));
}
};
document.addEventListener('AppleIDSignInOnSuccess', onOk);
document.addEventListener('AppleIDSignInOnFailure', onFail);
const stopWaiting = whenReady(
() => window.AppleID?.auth,
() => {
try {
window.AppleID.auth.init({
clientId: appleClientId,
scope: 'name email',
redirectURI: `${window.location.origin}/auth/apple/redirect`,
state: ssoRedirectState,
usePopup: !useRedirect,
});
Comment thread static/js/auth/AuthPage.jsx Outdated
Comment on lines +16 to +17
* SSO uses the existing backend callbacks (/api/auth/{google,apple}/callback). Email
* login/register use JSON+session endpoints (/api/auth/login, /api/auth/register).
Comment on lines +1 to +15
import React from 'react';
import PropTypes from 'prop-types';
import {InterfaceText} from "../Misc";

/**
* Divider — a horizontal rule with centered text ("or" / "או"), used between the
* SSO buttons and the email button on the auth choose screen. Figma `Form Card`.
*/
const Divider = () => (
<div className="sefaria-divider" role="separator"><InterfaceText>or</InterfaceText></div>
);

Divider.propTypes = { children: PropTypes.node };

export default Divider;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-issue — I'd skip this one. PropTypes is not unused (Divider is trivial, and an extraneous propTypes entry is harmless); this is lint noise, not a defect worth a round-trip on a PR with functional blockers.

@yodem yodem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the branch and ran it locally (real DB + built SSR bundle) rather than only reading the diff. Flagging as request-changes: as it stands the auth page is non-functional and it takes existing login down with it.

Verified by running the branch

Path HTTP id="s2" mount point present?
/login 200 0 — absent
/register 200 1
/texts 200 1

So a fresh load of /login has no React root at all (details inline on sefaria/views.py).

The four blockers

  1. /login renders a blank page — CustomLoginView no longer goes through render_template().
  2. SSR is dead site-widewindow is read in the ReaderApp constructor, which runs in Node.
  3. SSO can't work for three independent reasons — the Google/Apple SDK <script> tags are never loaded anywhere, pointer-events: none blocks the GIS overlay, and /api/auth/* doesn't exist.
  4. Email login POSTs to a route that doesn't exist, while this PR deletes the login.html that currently handles it. This also breaks e2e-tests/global-setup.ts, which logs in through the real /login form to mint storage state for every authenticated profile and hard-fails the suite if it can't. No tests or page objects were updated, and no tests were added — CI is green only because the auth suite doesn't run there.

Plus a P1 open redirect (inline on utils.js).

Credit where due: RTL is handled properly (logical inset-inline-* properties throughout), the password toggle is a correct type="button" with proper htmlFor/aria-invalid/role="alert" wiring, and committing the compiled .css/.css.map does match existing repo convention. Copilot's Divider.jsx propTypes nit and its doc-comment comment are noise — safe to ignore.

Comment thread sefaria/views.py
class CustomLoginView(StaticViewMixin, LoginView):
class CustomLoginView(LoginView):
authentication_form = SefariaLoginForm
template_name = 'base.html'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — this makes /login render a blank page.

Dropping StaticViewMixin and pointing template_name at base.html means this view never goes through render_template() / render_react_component(). So neither html nor renderStatic is ever set in the context — and base.html only emits the <div id="s2"> React mount point inside {% if html and not renderStatic %} / {% elif renderStatic %}.

I ran the branch against a real DB to confirm:

GET /login    -> 200, id="s2" occurrences: 0   <-- no React root
GET /register -> 200, id="s2" occurrences: 1
GET /texts    -> 200, id="s2" occurrences: 1

inReaderApp: true is still emitted, so client.jsx calls ReactDOM.render(component, document.getElementById('s2')) with null and throws. Any fresh load of /login (bookmark, refresh, external link, SSO redirect back) is a blank page.

register() below correctly calls render_template(...), which is why /register works — that isolates the bug to this class. Suggested fix: route CustomLoginView through render_template() with app_props the same way register() does.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why it commented this, you use render_template on line 100?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yodem i ignore all your (claude) comments for now - some of them are based on the fact that there was no server side, some claim that working things are not working.

Comment thread static/js/ReaderApp.jsx Outdated
translationLanguagePreference: props.translationLanguagePreference,
editorSaveState: 'saved',
notificationCount: props.notificationCount || 0,
showAuth: ['/login', '/register'].includes(window.location.pathname),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — this breaks SSR for the entire site, not just the auth pages.

This reads window.location in the constructor, unguarded. master has zero window access in this constructor — this is the first. But ReaderApp is server-rendered in Node: node/server.js:93 does ReactDOMServer.renderToString(ReaderApp(props)).

I built the SSR bundle and confirmed:

  • the compiled line includes(window.location.pathname) is present in the node-targeted server-bundle.js;
  • there is no window/jsdom shim anywhere in node/ or the bundle (global.window = / jsdom → 0 matches);
  • node -e "window.location.pathname"ReferenceError: window is not defined.

So renderToString throws on every SSR render. It won't 500 — render_react_component catches it and silently falls back to the loading placeholder — so this degrades quietly into "SSR is just off everywhere", which is easy to miss in review and painful for SEO/TTFB.

Fix: guard with typeof window !== 'undefined', or better, derive showAuth/authFlow/authNext from props.initialPath / props, which are already passed in from Django and safe on both sides.

Separately, Copilot is right that the exact-match is wrong: urls_shared.py uses re_path(fr'^login/?$'), so /login/ and /register/ are genuinely reachable and won't match here.

Comment thread static/js/auth/ChooseView.jsx Outdated
Sefaria.ssoSetRedirectState(ssoRedirectState);
}
const stopWaiting = whenReady(
() => window.google?.accounts?.id && googleBtnRef.current,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — the Google and Apple SDKs are never loaded, so both provider buttons are permanently disabled.

This polls for window.google?.accounts?.id, and the Apple effect polls for window.AppleID?.auth. But nothing on this branch ever loads those SDKs — searching the whole branch for gsi/client, appleid.auth, accounts.google.com, appleid.cdn-apple returns zero hits in any template or JS. The base.html diff only adds CSS.

So whenReady polls 80×100ms, gives up silently after 8s, googleReady/appleReady stay false, and both ProviderButtons render permanently disabled. The whole SSO surface of the PR is dead on arrival even before the missing /api/auth/* routes.

Needs the GIS + Apple JS <script> tags added to base.html (ideally only on the auth pages, and async/defer).

Minor, same file: onSSOResult omits the X-CSRFToken header that EmailView sends. Worth fixing now so it doesn't 403 the moment the backend lands.

Comment thread static/css/auth.scss Outdated
z-index: 1;
opacity: 0.0001;
overflow: hidden;
pointer-events: none;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirming Copilot here — this is real, and it's a second independent reason Google sign-in can't work.

pointer-events: none on the wrapper is inherited by the GIS <iframe> inside it — the > div, iframe rule only overrides width/height/margin, and nothing re-enables pointer events (pointer-events appears exactly once in this file: here).

That matters because ProviderButton gives the Google button no onClick — per the comment in ChooseView.jsx, GIS has no programmatic trigger, so the invisible iframe overlay receiving the click is the only activation path. With pointer events off, nothing is clickable.

Note the comment two lines above says the overlay "captures clicks correctly" — which is exactly what this rule prevents. Looks like a late edit that was never re-tested.

Comment thread static/js/auth/EmailView.jsx Outdated
captchaToken.current = '';
}
} else {
const res = await fetch('/api/auth/login', {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — email login POSTs to a route that does not exist, and this PR deletes the page that currently handles login.

There is no /api/auth/login in sefaria/urls_shared.py (searching the branch for api/auth returns nothing). /api/login/ exists but is TokenObtainPairView — it mints JWTs and does not establish a Django session, so it isn't a drop-in.

The PR description says server endpoints are "not in this PR", but that framing only covers the SSO callbacks. This is ordinary email login, and templates/registration/login.html — which currently handles it — is deleted in this same PR. Net effect on merge: nobody can log in.

The blast radius goes past the login page: e2e-tests/global-setup.ts drives this exact form to capture storage state for every authenticated e2e profile, and explicitly hard-fails the whole suite if login doesn't complete. e2e-tests/pages/loginPage.ts also matches on getByPlaceholder('Email Address') and getByRole('button', { name: 'Login' }), whereas the new UI uses placeholder you@example.com, a button labelled "Log In", and hides the form behind a "Continue with Email" click. Those page objects need updating in this PR.

Simplest path that keeps this PR self-contained: POST form-encoded credentials to the existing /login (Django's LoginView already handles it and sets the session), the same way the register branch above reuses /register with noredirect.

Comment thread static/js/auth/utils.js
return null;
}

export function safeNext(next) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — open redirect. next is attacker-controlled (query string) and flows straight into window.location.href in both EmailView and ChooseView.

This regex only rejects a second literal forward slash, so it blocks //evil.com but passes /\evil.com — and both Chrome and Firefox normalize backslashes to forward slashes when parsing a URL, so that navigates to the protocol-relative //evil.com:

safeNext("//evil.com")   // -> "/"            blocked
safeNext("/\\evil.com")  // -> "/\\evil.com"  PASSES -> lands on https://evil.com

So sefaria.org/login?next=/%5Cevil.com drops the user on an attacker's page immediately after a legitimate login — prime phishing setup, since the URL bar showed sefaria.org the whole way through.

The old flow POSTed next back to Django, which validated it server-side. Worth noting url_has_allowed_host_and_scheme is already imported and used in sefaria/views.py (line 112) — the correct pattern is right there. Either mirror it client-side (reject any \, then new URL(next, location.origin).origin === location.origin) or let the server own the post-auth redirect.

Comment thread static/js/sefaria/strings.js Outdated
"First Name": "שם פרטי",
"Last Name": "שם משפחה",
"Don't have an account?": 'אין לך חשבון?',
"Already have an account?": 'יש לך חשבון?',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This key already exists on master and is used by the pre-existing sign-up modal in Misc.jsx. Since _i18nInterfaceStrings is one flat object literal, this later declaration silently wins — so merging this changes the Hebrew text of that unrelated modal as an unreviewed side effect.

Either drop the duplicate and reuse the existing string, or scope it under the Auth context block (the pattern already used correctly for "Sign Up"/"Log In").

While here: "First Name"/"Last Name" duplicate existing keys, "Reset Password" is unused, and the error strings routed through authError() can never match — Django has already translated those messages server-side by the time they reach Sefaria._(), which keys on English.

Comment thread templates/base.html
<link rel="stylesheet" href="{% static 'css/themes/library-theme.css' %}">
<link rel="stylesheet" href="{% static 'css/themes/sheets-theme.css' %}">
<link rel="stylesheet" href="{% static 'css/common-components.css' %}">
<link rel="stylesheet" href="{% static 'css/common-component.css' %}">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things here:

  1. common-component.css (singular) sits directly above the pre-existing common-components.css (plural), and both are now loaded globally. Near-identical names one line apart is a maintenance trap — worth renaming to something unambiguous.
  2. This is where the missing Google/Apple SDK <script> tags need to go (see my comment on ChooseView.jsx) — without them the SSO buttons never enable.

Also: auth.css is loaded on every page sitewide for a feature used on two. Worth gating behind the auth views if that's easy.

YishaiGlasner and others added 6 commits July 29, 2026 14:28
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…_type

Checking localStorage.isReturningVisitor alone diverges from
Sefaria.isReturningVisitor() (static/js/sefaria/sefaria.js), which also
factors in sessionStorage.isNewVisitor. That caused first-session
visitors to be misreported as "old" on a page reload within the same
session, since markUserAsNewVisitor() sets localStorage.isReturningVisitor
immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
import_gravatar runs outside the transaction.atomic block (avoids holding
a DB transaction during the slow Gravatar fetch/GCS upload) but only
mutates the UserProfile in memory, so profile_pic_url/profile_pic_url_small
were never saved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
del request.session[...] raises if the token is already gone (e.g. session
expired between GET and POST), turning a successful reset into a 500.
…register_errors

str(ValidationError) formats as a repr'd list (e.g. "['msg']") when there's no
error code, breaking client-side EMAIL_EXISTS_ERRORS matching and showing
bracketed text to users.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@YishaiGlasner
YishaiGlasner requested review from yitzhakc and yodem July 29, 2026 13:13
YishaiGlasner and others added 12 commits July 29, 2026 17:45
- interface_language_test.py instantiated LanguageSettingsMiddleware() with
  no get_response, which only worked because of Django 4.2's now-removed
  default; pass a no-op callable instead.
- SaveUserTest asserted profile.settings['interface_language'] on a
  MagicMock, which never round-trips through __setitem__/__getitem__;
  assert the __setitem__ call instead.
- Google/AppleMobileTest asserted the sessionid cookie is absent after
  session.flush(), but Django deletes cookies by resending them empty with
  Max-Age=0, not by omitting them; assert the empty value instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…re signals

Google's rendered button lives in a cross-origin iframe, so method_chosen/
process_started were only ever synthesized on a successful credential —
closing the popup/account picker without finishing produced zero events at
all, on both desktop and the mobile redirect flow. Wires up GIS's official
click_listener for a real click signal (mirroring Apple's triggerApple),
adds a state-driven (not fixed-timeout) popup-abandonment detector that
can't be fooled by a slow backend or a long user pause, and adds a
pageshow/bfcache listener to catch the mobile browser-Back abandonment path
that beforeunload/popstate can't see. Also fixes a One Tap bug where a
successful credential return (getDismissedReason() === 'credential_returned')
was being miscoded as a failure event alongside the real success event.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
isDisplayed() reflects Google's own choice to show the One Tap widget, not
a user action — recording method_chosen/process_started there counted
intent for people who never touched it. Moves the funnel burst to the
credential callback (the earliest real proxy for "clicked", since One Tap
only offers accounts already signed into the browser and hands back a
credential unconditionally on click, before our own backend's success/
failure is known). If the user never engages, nothing fires at all - not
even flow_ended - since no attempt began. This also removes the
concluded/markConcluded race guard entirely, since dismissal notifications
no longer fire any events for it to race against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
window doesn't exist in Node, so a direct GET to /register or /login
server-rendered the plain library shell instead of AuthPage. React 16's
hydrate() then failed to cleanly replace that mismatched tree client-side,
leaving AuthPage's content stuck inside the stale panelContainer wrapper —
dropping .sefaria-auth-page (and its background) rather than just delaying it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AuthPage offset itself below the header via a hardcoded --header-height,
duplicating that push when a siteWideBanner already moved #main down in
flow. Centralize the offset as --header-offset on #main, which collapses
to 0 when a banner precedes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Slot was flush against the container edge; visually it needs a 1px
inset toward the start edge and 1px down to align correctly.
RegisterView's email field only checked required-ness on blur; format
was left entirely to server-side rejection on submit. Now also checks
the input's native type="email" validity and shows auth.invalid_email,
the same key sso/views.py's password_reset_api returns for a malformed
email, so the message matches server-side validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ter navigation

next was captured from currentPath() at render time in header buttons, and
unconditionally from currentPath() on every fresh auth navigation - so
clicking Login/Sign up while already on /login or /register baked the
auth page's own URL into next, growing on every repeat. next is now
always resolved centrally in ReaderApp.openURL at the moment of
navigation: reused from the already-preserved authPath when already in
the auth flow, otherwise read fresh from the current page.
…n mobile

Previously it sat 16px past the padding boundary (40px from the true edge);
now it's inset 16px from the edge itself, overlapping into the padding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@yodem

yodem commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Raising this as a finding rather than a change — it's your branch and your call.

While adding sso_only_account to api/login/ for the mobile app (#3580), I found four implementations of "which SSO providers is this account linked to" on this branch:

sso/views.py:email_login inline socialaccount_set query + has_usable_password() gate
sefaria/forms.py:clean_email its own SocialAccount.objects.filter(user=user) query
reader/views.py (account settings) socialaccount_set.values_list('provider', flat=True)
static/js/auth/RegisterView.jsx string-matches clean_email's English back into {code, providers}

The last pair is the one worth a look. clean_email holds structured SocialAccount rows and flattens them into English prose:

raise forms.ValidationError("This email address is already registered via Google Sign-In.")

and the client regex-reconstructs the structure that was just discarded:

'This email address is already registered via Google Sign-In.': { code: 'sso_only_account', providers: ['google'] },

The Sefaria mobile app now does the same string-match, against the same sentences. So one English sentence is load-bearing API for two clients in two repos, with no test or type that would catch a copy edit — a wording change in forms.py breaks both silently.

Notably, login already does this correctlyemail_login returns structured _auth: {code, providers} and ErrorBanner reads it. Register is the only path that round-trips through prose.

A possible shape, if you think it's worth it:

  • one linked_providers(user) primitive behind all the backend call sites
  • clean_email raises with code='sso_only_account' and the providers in params, so the register response can carry the same _auth shape login already returns
  • keep the English message alongside it, so clients that predate _auth keep working
  • keep account_exists (a plain password account) distinct — it must never report as sso_only_account, or the UI tells the user to click a Google button that can't help them
  • both clients then read _auth.code and their English lookup tables go away

I had implemented this and have since removed it from #3580 — it was out of scope for a mobile fix and it's your branch's code. Happy to open it as a separate PR against sso if you'd like it, or to leave it entirely.

🤖 Generated with Claude Code

YishaiGlasner and others added 8 commits August 5, 2026 08:53
reCAPTCHA always loaded in its default (English/LTR) language regardless
of the page's interface language, which also threw off widget alignment
on Hebrew/RTL pages where the surrounding layout is RTL but the widget
itself rendered LTR. Passing hl= on the script URL (the only place
reCAPTCHA's language is configurable) ties it to request.interfaceLang,
fixing both the language mismatch and the alignment issue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	build/ci/createJobFromRollout.sh
#	pytest.ini
#	reader/views.py
#	sefaria/system/context_processors.py
#	sefaria/views.py
#	static/js/sefaria/i18n/interface-context/en.json
#	static/js/sefaria/i18n/interface-context/he.json
safeNext's regex only rejected a second literal "/", letting a backslash
(e.g. "/\evil.com") slip through and get normalized by the browser into a
protocol-relative "//evil.com". Rewritten to resolve `next` against a
placeholder base via the URL parser and compare origins, so the browser's
own normalization is what decides safety instead of a hand-rolled regex.

register() in sefaria/views.py echoed the client-supplied `next` back as
`redirect` in its JSON response (consumed verbatim by RegisterView.jsx)
with no host/scheme validation at all. Added the same
url_has_allowed_host_and_scheme check CustomLogoutView already uses.
…emailing

Users with SSO-only accounts got a generic reset-email response when
requesting a password reset, even though they can't use a password to
log in. password_reset_api now returns the same sso_only_account error
as email_login. Extracted the shared check into _sso_only_account_error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a small sefaria-blue spinner + "Loading"/"טוען" text in the AuthCard
sub area while ChooseView waits on a popup-mode Google/Apple SSO round
trip, and while ForgotView/LoginView/RegisterView await their submit
response. Also fix FormView's submitting flag being cleared right
before a Login/Register redirect actually fires, which caused a brief
flash back to the normal sub text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Native iOS Sign in with Apple was rejected with a bare
`auth.social_signin_failed`; the server-side log showed
`apple token verification failed error=['Invalid token.']`.

Apple stamps `aud` differently per surface: the Services ID
(org.sefaria.web.signin) for the web JS SDK, but the app's bundle ID
(org.sefaria.sefariaApp) for a native iOS sign-in. Both have to be
accepted.

The settings declared that list under `APP['settings']['audience']`,
but allauth has no such setting -- it is silently ignored.
`AppleProvider.get_auds()` is literally
`self.app.client_id.split(",")`, so the allowed audiences come from a
comma-separated `client_id` and nothing else. Every native iOS token
therefore failed audience validation while web sign-in kept working.

Join the two IDs into `client_id` and drop the dead `audience` key.
Web behaviour is unchanged: `AppleOAuth2Adapter.client_class` is
`AppleOAuth2Client`, whose `get_client_id()` returns
`consumer_key.split(",")[0]`, so the Services ID stays first and
remains what the authorize redirect, the token exchange, and the
client-secret JWT's `sub` all carry. `reader/views.py` builds the web
SDK's `appleClientId` from the `APPLE_SSO_CLIENT_ID` setting directly
and is untouched. The change is strictly additive -- it only widens the
set of accepted audiences.

No mobile-client change is required.


Claude-Session: https://claude.ai/code/session_01P6SyKh9dkDroXyNma5X9UN

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants