Merge pull request #100 from christianlouis/copilot/decouple-gmail-login-token-gathering

Decouple Gmail permissions from Google Sign-In
This commit is contained in:
Christian Krakau-Louis
2026-03-27 22:04:24 +01:00
committed by GitHub
4 changed files with 13 additions and 81 deletions
+3
View File
@@ -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.
+8 -77
View File
@@ -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}
+1 -3
View File
@@ -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,
+1 -1
View File
@@ -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`)