"""Google OAuth provider for FastAPI. Group memberships are sourced via Application Default Credentials in ``app.auth.group_sync.fetch_user_groups`` (no per-user OAuth scope needed for that path), so the OAuth flow only handles authentication and returns a session JWT. Membership writes go to ``user_group_members``. """ import os import logging from authlib.integrations.starlette_client import OAuth from fastapi import APIRouter, Request from fastapi.responses import RedirectResponse from app.auth.jwt import create_access_token from app.auth._common import safe_next_path from app.instance_config import get_allowed_domains logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth/google", tags=["auth"]) oauth = OAuth() GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") def is_available() -> bool: return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) def _setup_oauth(): if not is_available(): return oauth.register( name="google", client_id=GOOGLE_CLIENT_ID, client_secret=GOOGLE_CLIENT_SECRET, server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", client_kwargs={"scope": "openid email profile"}, ) _setup_oauth() @router.get("/login") async def google_login(request: Request): """Redirect to Google OAuth. Honors `?next=` by stashing the sanitized value in the session so the callback can redirect there instead of the default /dashboard. The session is the right stash — OAuth flow is stateful and the `state` param is managed by Authlib. """ if not is_available(): return RedirectResponse(url="/login?error=google_not_configured") next_path = safe_next_path(request.query_params.get("next"), default="") if next_path: request.session["login_next"] = next_path else: # Clear any stale value from an earlier aborted attempt. request.session.pop("login_next", None) redirect_uri = str(request.url_for("google_callback")) return await oauth.google.authorize_redirect(request, redirect_uri) @router.get("/callback") async def google_callback(request: Request): """Handle Google OAuth callback.""" if not is_available(): return RedirectResponse(url="/login?error=google_not_configured") try: token = await oauth.google.authorize_access_token(request) user_info = token.get("userinfo", {}) email = user_info.get("email", "") name = user_info.get("name", "") if not email: return RedirectResponse(url="/login?error=no_email") # Domain check allowed = get_allowed_domains() if allowed: domain = email.split("@")[-1] if domain not in allowed: return RedirectResponse(url="/login?error=domain_not_allowed") # Find or create user, sync Workspace group memberships into # user_group_members. from src.db import ( get_system_db, SYSTEM_ADMIN_GROUP, SYSTEM_EVERYONE_GROUP, ) from src.repositories.users import UserRepository from src.repositories.user_groups import UserGroupsRepository from src.repositories.user_group_members import UserGroupMembersRepository from app.auth.group_sync import fetch_user_groups import uuid # Optional Workspace-group prefix filter + system-group mapping. Read # per-request so test fixtures and operators can flip via env without # restarting the process. Empty prefix = legacy behavior (mirror all). prefix = os.environ.get( "AGNES_GOOGLE_GROUP_PREFIX", "" ).strip().lower() admin_email = os.environ.get( "AGNES_GROUP_ADMIN_EMAIL", "" ).strip().lower() everyone_email = os.environ.get( "AGNES_GROUP_EVERYONE_EMAIL", "" ).strip().lower() conn = get_system_db() try: repo = UserRepository(conn) user = repo.get_by_email(email) if not user: user_id = str(uuid.uuid4()) repo.create(id=user_id, email=email, name=name) # v39: subscribe new user to every system plugin so the # mandatory tier reaches them on their first session # without an admin reconcile. Fail-soft — the import + # fanout sit inside the same conn used for repo.create # above, so a transient marketplace_plugins read failure # doesn't block sign-in. try: from src.repositories.user_curated_subscriptions import ( UserCuratedSubscriptionsRepository, ) UserCuratedSubscriptionsRepository( conn ).fanout_system_for_user(user_id) except Exception: logger.exception( "system-plugin fanout failed for new user %s", email, ) user = repo.get_by_email(email) if not bool(user.get("active", True)): return RedirectResponse(url="/login?error=deactivated") # Sync Workspace groups → user_group_members (source='google_sync'). # Fail-soft: any error leaves the previous membership snapshot in # place; admin-added rows survive regardless. members_repo = UserGroupMembersRepository(conn) try: group_names = fetch_user_groups(email) # `fetch_user_groups` is fail-soft and returns [] for both # "user genuinely has no groups" and "transient API failure". # Empty result is treated as "no change": preserve the # previous snapshot rather than wiping it on a transient # hiccup. Admin-added rows survive regardless. if not group_names: logger.info( "Google group sync for %s: empty result, " "preserving existing memberships", email, ) else: # Lower-cased Workspace email of each group; comparisons # against admin_email/everyone_email/prefix are all # case-insensitive. fetched = [g.lower() for g in group_names] if prefix: relevant = [g for g in fetched if g.startswith(prefix)] else: relevant = list(fetched) # Login gate: prefix is set AND fetch returned a # non-empty list AND none of those groups match the # prefix → user is signed into Google but is not a # member of any group permitted to use this Agnes # instance. Pass-through-on-empty-fetch is preserved # above (transient API failures must not lock users # out), so this branch fires only when we got a real # answer that excluded them. if prefix and not relevant: logger.info( "Google login denied for %s: no group with " "prefix %r in %s", email, prefix, fetched, ) return RedirectResponse( url="/login?error=not_in_allowed_group" ) ug_repo = UserGroupsRepository(conn) group_ids: list[str] = [] for email_addr in relevant: if admin_email and email_addr == admin_email: sys_admin = ug_repo.get_by_name( SYSTEM_ADMIN_GROUP ) if sys_admin: group_ids.append(sys_admin["id"]) continue if everyone_email and email_addr == everyone_email: sys_everyone = ug_repo.get_by_name( SYSTEM_EVERYONE_GROUP ) if sys_everyone: group_ids.append(sys_everyone["id"]) continue # Regular synced group: name = full email. ensure() # is get-or-create-by-name and stamps # created_by='system:google-sync' on first create. g = ug_repo.ensure(email_addr) group_ids.append(g["id"]) members_repo.replace_google_sync_groups( user["id"], group_ids, added_by="system:google-sync", ) logger.info( "Google group sync for %s: %d group(s) " "(filtered from %d fetched, prefix=%r) [%s]", email, len(group_ids), len(fetched), prefix, ", ".join(relevant), ) except Exception as sync_err: # noqa: BLE001 - fail-soft by design logger.warning( "Google group sync failed for %s: %s", email, sync_err ) finally: conn.close() # Issue JWT — identity-only, authorization derives from # user_group_members at request time (see app.auth.access). jwt_token = create_access_token(user["id"], user["email"]) # Redirect to the post-login target. Prefer the value stashed by # google_login() — re-sanitize defensively in case of session tampering. # default=None → safe_next_path resolves to the operator-configured # home route (AGNES_HOME_ROUTE / instance.home_route / /dashboard). target = safe_next_path(request.session.pop("login_next", None)) # Redirect to target with token in cookie. Match password/email providers: # Secure only when DOMAIN is set (production with TLS), so the cookie is # actually sent over plain HTTP in dev. use_secure = os.environ.get("DOMAIN", "") != "" response = RedirectResponse(url=target, status_code=302) response.set_cookie( key="access_token", value=jwt_token, httponly=True, max_age=86400, samesite="lax", secure=use_secure, ) return response except Exception as e: logger.error(f"Google OAuth error: {e}") return RedirectResponse(url="/login?error=oauth_failed")