Merge pull request #100 from christianlouis/copilot/decouple-gmail-login-token-gathering
Decouple Gmail permissions from Google Sign-In
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user