From d7b7acb637db3998e892e7b34f2a3bcba2065885 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 20 Jun 2026 20:04:23 +0200 Subject: [PATCH] feat(auth): manage Google OAuth credentials from the database (epic 6b) - Build the Google OAuth client lazily from sysconfig (DB override, env fallback) and re-register when the credentials change, so the install wizard / admin can set them without a restart. - New 'google' config group (google_client_id/secret, encrypted) on the Configuration page; the token-refresh reads the creds the same way. - Public GET /auth/config exposes google_enabled; the login page hides 'Continue with Google' when Google OAuth isn't configured (email+password still works). --- backend/app/auth.py | 87 ++++++++++++++++++++---- backend/app/sysconfig.py | 4 ++ backend/app/youtube/client.py | 4 +- frontend/src/components/ConfigPanel.tsx | 2 +- frontend/src/components/Welcome.tsx | 21 ++++-- frontend/src/i18n/locales/de/config.json | 3 + frontend/src/i18n/locales/en/config.json | 3 + frontend/src/i18n/locales/hu/config.json | 3 + frontend/src/lib/api.ts | 3 + 9 files changed, 106 insertions(+), 24 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index e3fc79a..88f4549 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -75,14 +75,40 @@ log = logging.getLogger("subfeed.auth") router = APIRouter(prefix="/auth", tags=["auth"]) -oauth = OAuth() -oauth.register( - name="google", - client_id=settings.google_client_id, - client_secret=settings.google_client_secret, - server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", - client_kwargs={"scope": BASE_SCOPES}, -) +# The Google OAuth client is built lazily from the CURRENT credentials (DB override via sysconfig, +# else env), so the install wizard / admin can set or change them at runtime without a restart. The +# authlib registry is rebuilt only when the creds change (cheap, first use after a change). +_oauth = OAuth() +_oauth_creds: tuple[str, str] | None = None + + +def google_oauth(db: Session): + """The configured authlib Google client, or None when no credentials are set (Google sign-in + is then disabled — email+password still works).""" + global _oauth, _oauth_creds + cid = sysconfig.get_str(db, "google_client_id") + csec = sysconfig.get_str(db, "google_client_secret") + if not cid or not csec: + return None + if _oauth_creds != (cid, csec): + _oauth = OAuth() + _oauth.register( + name="google", + client_id=cid, + client_secret=csec, + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": BASE_SCOPES}, + ) + _oauth_creds = (cid, csec) + return _oauth.google + + +def google_enabled(db: Session) -> bool: + """Whether Sign in with Google is available (both client id + secret resolve to non-empty).""" + return bool( + sysconfig.get_str(db, "google_client_id") + and sysconfig.get_str(db, "google_client_secret") + ) def has_read_scope(user: User) -> bool: @@ -178,11 +204,16 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None: @router.get("/login") -async def login(request: Request): +async def login(request: Request, db: Session = Depends(get_db)): # access_type=offline ensures a refresh_token on first authorization. We avoid # prompt=consent so returning users get a quick sign-in; the stored refresh token # is kept when Google doesn't re-issue one (see the callback). - return await oauth.google.authorize_redirect( + client = google_oauth(db) + if client is None: + # Google sign-in isn't configured on this instance — land back on the login page (the UI + # hides the button, so this is just a safety net for a stale/direct hit). + return RedirectResponse(url="/") + return await client.authorize_redirect( request, settings.oauth_redirect_url, access_type="offline", @@ -198,8 +229,11 @@ async def callback( background: BackgroundTasks, db: Session = Depends(get_db), ): + client = google_oauth(db) + if client is None: + return RedirectResponse(url="/") try: - token = await oauth.google.authorize_access_token(request) + token = await client.authorize_access_token(request) except OAuthError as exc: raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}") @@ -661,15 +695,22 @@ def require_human(user: User = Depends(current_user)) -> User: @router.get("/link") -async def link_google(request: Request, user: User = Depends(current_user)): +async def link_google( + request: Request, + user: User = Depends(current_user), + db: Session = Depends(get_db), +): """Start linking a Google account to the signed-in (e.g. password) account. Requests only the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback sees `oauth_link_uid` and attaches the identity instead of creating a new account.""" if user.is_demo: return RedirectResponse(url="/") + client = google_oauth(db) + if client is None: + return RedirectResponse(url="/?link=error") request.session["oauth_link_uid"] = user.id request.session["oauth_link_explicit"] = True - return await oauth.google.authorize_redirect( + return await client.authorize_redirect( request, settings.oauth_redirect_url, access_type="offline", @@ -705,7 +746,10 @@ def set_password( @router.get("/upgrade") async def upgrade( - request: Request, access: str = "read", user: User = Depends(current_user) + request: Request, + access: str = "read", + user: User = Depends(current_user), + db: Session = Depends(get_db), ): """Incremental consent for the onboarding wizard. `access=read` grants YouTube read-only (enough to build the feed); `access=write` additionally grants management @@ -716,11 +760,14 @@ async def upgrade( # straight home (no YouTube link is ever attached to it) rather than shown a raw 403. if user.is_demo: return RedirectResponse(url="/") + client = google_oauth(db) + if client is None: + return RedirectResponse(url="/") # Attach the grant to THIS account in the callback. Without it a password account (no # google_sub) would get a brand-new, separate Google user created instead of being linked. request.session["oauth_link_uid"] = user.id scope = WRITE_SCOPES if access == "write" else READ_SCOPES - return await oauth.google.authorize_redirect( + return await client.authorize_redirect( request, settings.oauth_redirect_url, access_type="offline", @@ -739,3 +786,13 @@ async def me(user: User = Depends(current_user)) -> dict: "avatar_url": user.avatar_url, "role": user.role, } + + +@router.get("/config") +def auth_config(db: Session = Depends(get_db)) -> dict: + """Public: which sign-in options this instance offers, so the login page can hide what's off + (e.g. no Google button when Google OAuth isn't configured).""" + return { + "google_enabled": google_enabled(db), + "allow_registration": sysconfig.get_bool(db, "allow_registration"), + } diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index b5db3dc..51b3ad3 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -50,6 +50,10 @@ SPECS: tuple[ConfigSpec, ...] = ( ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000), # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) --- ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True), + # --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the + # install wizard / Configuration page so no restart is needed when the creds change. --- + ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True), + ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True), # --- Access / registration --- ConfigSpec("allow_registration", "bool", "access", "allow_registration"), ) diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index cc7ce09..0692171 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -66,8 +66,8 @@ class YouTubeClient: resp = self._http.post( GOOGLE_TOKEN_URL, data={ - "client_id": settings.google_client_id, - "client_secret": settings.google_client_secret, + "client_id": sysconfig.get_str(self.db, "google_client_id"), + "client_secret": sysconfig.get_str(self.db, "google_client_secret"), "refresh_token": refresh, "grant_type": "refresh_token", }, diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index b614eec..1da0e7a 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -12,7 +12,7 @@ import Tabs, { usePersistedTab } from "./Tabs"; // applied together via one Save/Discard bar (consistent with the Settings page); nothing hits // the server until Save. Group order is fixed where known so logically related settings (e.g. // Email/SMTP) stay together. -const GROUP_ORDER = ["access", "email", "youtube", "quota", "backfill", "shorts", "batch"]; +const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch"]; type SaveState = "idle" | "saving" | "saved" | "error"; export default function ConfigPanel() { diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index df0daa9..3d91468 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -243,6 +243,13 @@ function AuthCard() { const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + // Whether to offer "Continue with Google" — hidden when the instance has no Google OAuth + // configured. Optimistic (most instances have it); the rare Google-less instance just sees it + // disappear once the config loads. + const [googleEnabled, setGoogleEnabled] = useState(true); + useEffect(() => { + api.authConfig().then((c) => setGoogleEnabled(c.google_enabled)).catch(() => {}); + }, []); const [done, setDone] = useState(""); // success/info message replacing the form // Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated @@ -423,12 +430,14 @@ function AuthCard() { {t("welcome.auth.or")} - - {t("welcome.auth.google")} - + {googleEnabled && ( + + {t("welcome.auth.google")} + + )} {!showDemo ? (