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] ## [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 ### 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**: 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. - **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 fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from urllib.parse import quote as urlquote from urllib.parse import quote as urlquote
import logging import logging
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db 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 ( from app.core.metrics import (
AUTH_LOGINS_TOTAL, AUTH_LOGINS_TOTAL,
AUTH_REGISTRATIONS_TOTAL, AUTH_REGISTRATIONS_TOTAL,
OAUTH_CALLBACKS_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.models.schemas import Token, UserCreate, UserResponse, GoogleAuthRequest
from app.services.auth_service import oauth_service 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() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# All scopes requested during Google Sign-In so users only go through one OAuth # Scopes requested during Google Sign-In only basic profile information.
# consent screen for both login and Gmail API access. # Gmail API access is granted separately via the "Connect Gmail" flow in
# GMAIL_SCOPES (gmail.insert, gmail.labels, gmail.readonly) are imported from # Settings (/providers/gmail/authorize-url).
# gmail_service so the scope list stays in sync with what GmailService uses.
GOOGLE_LOGIN_SCOPES = [ GOOGLE_LOGIN_SCOPES = [
"openid", "openid",
"email", "email",
"profile", "profile",
*GMAIL_SCOPES,
] ]
@@ -251,69 +247,6 @@ async def google_oauth(
await db.commit() await db.commit()
await db.refresh(user) 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 # Create tokens
tokens = oauth_service.create_tokens_for_user(user) tokens = oauth_service.create_tokens_for_user(user)
@@ -323,7 +256,7 @@ async def google_oauth(
@router.get("/google/authorize-url") @router.get("/google/authorize-url")
async def get_google_authorize_url(redirect_uri: str): 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)) scope = urlquote(" ".join(GOOGLE_LOGIN_SCOPES))
auth_url = ( auth_url = (
"https://accounts.google.com/o/oauth2/v2/auth" "https://accounts.google.com/o/oauth2/v2/auth"
@@ -331,9 +264,7 @@ async def get_google_authorize_url(redirect_uri: str):
"&response_type=code" "&response_type=code"
f"&scope={scope}" f"&scope={scope}"
f"&redirect_uri={redirect_uri}" f"&redirect_uri={redirect_uri}"
"&access_type=offline" "&prompt=select_account"
"&prompt=consent"
"&include_granted_scopes=true"
) )
return {"authorization_url": auth_url} return {"authorization_url": auth_url}
+1 -3
View File
@@ -25,9 +25,7 @@ class OAuthService:
def _register_google(self): def _register_google(self):
"""Register Google OAuth2 provider""" """Register Google OAuth2 provider"""
if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET: if settings.GOOGLE_CLIENT_ID and settings.GOOGLE_CLIENT_SECRET:
from app.services.gmail_service import GMAIL_SCOPES scope = "openid email profile"
scope = " ".join(["openid", "email", "profile", *GMAIL_SCOPES])
self.oauth.register( self.oauth.register(
name="google", name="google",
client_id=settings.GOOGLE_CLIENT_ID, 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] Per-user SMTP configuration (UX + backend)
- [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling - [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] 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] 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] **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`) - [x] **Logging & reporting**: per-email ProcessingLog capture in worker; user `/logs` page; admin `/admin/logs` page; GDPR masking utilities (`gdpr.py`)