diff --git a/CHANGELOG.md b/CHANGELOG.md index d3eaa1a..0ffb4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Decoupled Gmail permissions from Google Sign-In**: The "Sign in with Google" OAuth flow now only requests basic profile scopes (`openid`, `email`, `profile`) instead of also requesting Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`). Users can grant Gmail access separately via the "Connect Gmail" button in Settings. This results in a simpler, permission-free login experience. + ### Fixed - **CI `update-k8s-manifest` job**: Fixed image tag computation and `yq` update patterns to target `registry.cklnet.com` (private registry) instead of `ghcr.io`. The k8s manifest uses private registry image references, so the previous GHCR-based patterns never matched and no tag updates were applied. - **CI `update-k8s-manifest` job**: Enhanced the PAT validation step to verify the token actually has read access to the `k8s-cluster-state` repository (via a GitHub API probe) before attempting checkout, preventing a 403 "Write access to repository not granted" failure when the PAT exists but lacks the necessary repository access. diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index e17f427..d60ace9 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -6,36 +6,32 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from urllib.parse import quote as urlquote import logging from app.core.config import settings from app.core.database import get_db -from app.core.security import verify_password, get_password_hash, encrypt_credential +from app.core.security import verify_password, get_password_hash from app.core.metrics import ( AUTH_LOGINS_TOTAL, AUTH_REGISTRATIONS_TOTAL, OAUTH_CALLBACKS_TOTAL, ) -from app.models.database_models import User, SubscriptionTier, GmailCredential +from app.models.database_models import User, SubscriptionTier from app.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest from app.services.auth_service import oauth_service -from app.services.gmail_service import GmailService, GMAIL_SCOPES -from app.utils.gmail_labels import build_gmail_credential_scopes router = APIRouter() logger = logging.getLogger(__name__) -# All scopes requested during Google Sign-In so users only go through one OAuth -# consent screen for both login and Gmail API access. -# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from -# gmail_service so the scope list stays in sync with what GmailService uses. +# Scopes requested during Google Sign-In – only basic profile information. +# Gmail API access is granted separately via the "Connect Gmail" flow in +# Settings (/providers/gmail/authorize-url). GOOGLE_LOGIN_SCOPES = [ "openid", "email", "profile", - *GMAIL_SCOPES, ] @@ -251,69 +247,6 @@ async def google_oauth( await db.commit() await db.refresh(user) - # If Gmail tokens were returned (Gmail scopes were granted), store them so - # users don't need a separate "Connect Gmail" step after signing in. - google_access_token = user_info.get("access_token") - google_refresh_token = user_info.get("refresh_token") - if google_access_token: - try: - gmail_service = GmailService( - access_token=google_access_token, - refresh_token=google_refresh_token, - client_id=settings.GOOGLE_CLIENT_ID, - client_secret=settings.GOOGLE_CLIENT_SECRET, - ) - is_valid = await gmail_service.verify_access() - if is_valid: - token_expiry = datetime.now(timezone.utc) + timedelta( - seconds=int(user_info.get("expires_in", 3600)) - ) - encrypted_access = encrypt_credential(google_access_token) - encrypted_refresh = ( - encrypt_credential(google_refresh_token) - if google_refresh_token - else None - ) - scope_list = user_info.get("scope", "").split() - - cred_result = await db.execute( - select(GmailCredential).where(GmailCredential.user_id == user.id) - ) - existing_cred = cred_result.scalar_one_or_none() - - if existing_cred: - existing_cred.gmail_email = email # type: ignore[assignment] - existing_cred.encrypted_access_token = encrypted_access # type: ignore[assignment] - if encrypted_refresh: - existing_cred.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment] - existing_cred.token_expiry = token_expiry # type: ignore[assignment] - existing_cred.scopes = build_gmail_credential_scopes( # type: ignore[assignment] - scope_list, - existing_cred.import_label_templates, - ) - existing_cred.is_valid = True # type: ignore[assignment] - existing_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment] - else: - new_cred = GmailCredential( - user_id=user.id, - gmail_email=email, - encrypted_access_token=encrypted_access, - encrypted_refresh_token=encrypted_refresh, - token_expiry=token_expiry, - scopes=build_gmail_credential_scopes(scope_list), - is_valid=True, - last_verified_at=datetime.now(timezone.utc), - ) - db.add(new_cred) - - await db.commit() - logger.info(f"Gmail credentials stored for user: {email}") - except Exception as e: - # Non-fatal: login succeeds even if Gmail credential storage fails - logger.warning( - f"Could not store Gmail credentials during Google login for {email}: {e}" - ) - # Create tokens tokens = oauth_service.create_tokens_for_user(user) @@ -323,7 +256,7 @@ async def google_oauth( @router.get("/google/authorize-url") async def get_google_authorize_url(redirect_uri: str): - """Get Google OAuth2 authorization URL requesting all necessary scopes.""" + """Get Google OAuth2 authorization URL for sign-in (profile scopes only).""" scope = urlquote(" ".join(GOOGLE_LOGIN_SCOPES)) auth_url = ( "https://accounts.google.com/o/oauth2/v2/auth" @@ -331,9 +264,7 @@ async def get_google_authorize_url(redirect_uri: str): "&response_type=code" f"&scope={scope}" f"&redirect_uri={redirect_uri}" - "&access_type=offline" - "&prompt=consent" - "&include_granted_scopes=true" + "&prompt=select_account" ) return {"authorization_url": auth_url} diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 7253d05..de3458e 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -25,9 +25,7 @@ class OAuthService: def _register_google(self): """Register Google OAuth2 provider""" if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET: - from app.services.gmail_service import GMAIL_SCOPES - - scope = " ".join(["openid", "email", "profile", *GMAIL_SCOPES]) + scope = "openid email profile" self.oauth.register( name="google", client_id=settings.GOOGLE_CLIENT_ID, diff --git a/docs/TODO.md b/docs/TODO.md index 806133d..f3f87c5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -207,7 +207,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Per-user SMTP configuration (UX + backend) - [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling - [x] Configurable Gmail import labels (default `{{source_email}}` + `imported`, editable in Settings with reset-to-default action) -- [x] Unified Google OAuth flow: sign-in requests all Gmail scopes; single `/auth/callback` redirect URI needed in Google Console +- [x] Decoupled Google Sign-In from Gmail API permissions: login now requests only basic profile scopes; Gmail access is granted separately via Settings - [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking) - [x] **Debug email**: "Send Debug Email" button in Settings injects a test message (from christian@docuelevate.org, dated today, labelled `test` + `imported`, placed in inbox) to verify end-to-end Gmail API delivery - [x] **Logging & reporting**: per-email ProcessingLog capture in worker; user `/logs` page; admin `/admin/logs` page; GDPR masking utilities (`gdpr.py`)