From fb74eeaafa513ff8fe07bce0c2263b9a162bfb2c Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 15 Feb 2026 14:12:55 +0530 Subject: [PATCH] Cleanup OAuth setup dialog Also use a random port for the callback server --- src/calibre/gui2/dialogs/oauth_setup.py | 157 +++++++++++------------- src/calibre/utils/oauth2.py | 20 +-- 2 files changed, 72 insertions(+), 105 deletions(-) diff --git a/src/calibre/gui2/dialogs/oauth_setup.py b/src/calibre/gui2/dialogs/oauth_setup.py index 59c29dc9a6..18c12a1853 100644 --- a/src/calibre/gui2/dialogs/oauth_setup.py +++ b/src/calibre/gui2/dialogs/oauth_setup.py @@ -1,40 +1,44 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2026, Kovid Goyal +from functools import partial from qt.core import ( - QApplication, QComboBox, QDialog, - QEvent, + QDialogButtonBox, QFormLayout, QGroupBox, - QHBoxLayout, + QIcon, QLabel, - QProgressDialog, - QPushButton, + QStackedLayout, Qt, QTextBrowser, QVBoxLayout, + QWidget, + pyqtSignal, + sip, ) -from calibre.gui2 import error_dialog, info_dialog +from calibre.gui2 import error_dialog class OAuth2SetupDialog(QDialog): GMAIL_DOMAINS = ('@gmail.com', '@googlemail.com') OUTLOOK_DOMAINS = ('@outlook.com', '@hotmail.com', '@live.com', '@msn.com') + flow_finished = pyqtSignal(object, str) # tokens, error_msg def __init__(self, parent, provider='gmail', email=''): super().__init__(parent) - self.setWindowTitle(_('Setup OAuth 2.0 Authentication')) + self.setWindowTitle(_('Setup authentication')) self.resize(550, 400) self.tokens = None self.email_address = email from calibre.utils.oauth2 import get_available_providers self.available_providers = get_available_providers() self.provider, self.provider_detected = self._detect_provider(email, provider) + self.flow_finished.connect(self.on_flow_finish, type=Qt.ConnectionType.QueuedConnection) self.setup_ui() def _detect_provider(self, email, fallback='gmail'): @@ -56,17 +60,23 @@ class OAuth2SetupDialog(QDialog): def setup_ui(self): layout = QVBoxLayout(self) - header = QLabel(_('

Setup OAuth 2.0 Authentication

' + header = QLabel(_('

Setup OAuth 2.0 authentication

' '

OAuth 2.0 is the recommended authentication method for Gmail and Outlook. ' - 'It is more secure than using passwords and does not require app-specific passwords.

')) + 'It is more secure than using passwords.')) header.setWordWrap(True) layout.addWidget(header) + self.stack = s = QStackedLayout() + layout.addLayout(s) + self.info_widget = w = QWidget(self) + s.addWidget(w) + w.l = l = QVBoxLayout(w) - provider_group = QGroupBox(_('Email Provider')) + provider_group = QGroupBox(_('Email provider')) + l.addWidget(provider_group) provider_layout = QFormLayout(provider_group) self.provider_combo = QComboBox() for provider_id, provider_display in self.available_providers: - self.provider_combo.addItem(_(provider_display), provider_id) + self.provider_combo.addItem(provider_display, provider_id) idx = self.provider_combo.findData(self.provider) if idx >= 0: self.provider_combo.setCurrentIndex(idx) @@ -86,7 +96,7 @@ class OAuth2SetupDialog(QDialog): self.provider_label = QLabel(f'{provider_name}') provider_layout.addRow(_('Provider:'), self.provider_label) self.provider_combo.hide() - layout.addWidget(provider_group) + l.addWidget(provider_group) instructions_group = QGroupBox(_('Instructions')) instructions_layout = QVBoxLayout(instructions_group) @@ -94,98 +104,71 @@ class OAuth2SetupDialog(QDialog): instructions.setOpenExternalLinks(False) instructions.setMaximumHeight(120) instructions.setHtml(_('
    ' - '
  1. Click "Authorize" below
  2. ' - "
  3. Your web browser will open to the provider's login page
  4. " - '
  5. Sign in and grant calibre permission to send email
  6. ' - '
  7. The browser will redirect back automatically
  8. ' + '
  9. Click "Authorize" below.
  10. ' + "
  11. Your web browser will open to the provider's login page.
  12. " + '
  13. Sign in and grant calibre permission to send email.
  14. ' + '
  15. The browser will redirect back automatically.
  16. ' '
' '

Note: Calibre never sees your password.

')) instructions_layout.addWidget(instructions) - layout.addWidget(instructions_group) + l.addWidget(instructions_group) self.status_label = QLabel() self.status_label.setWordWrap(True) - layout.addWidget(self.status_label) + s.addWidget(self.status_label) - button_layout = QHBoxLayout() - button_layout.addStretch() - self.authorize_button = QPushButton(_('Authorize')) - self.authorize_button.setDefault(True) - self.authorize_button.clicked.connect(self.start_oauth_flow) - button_layout.addWidget(self.authorize_button) - self.cancel_button = QPushButton(_('Cancel')) - self.cancel_button.clicked.connect(self.reject) - button_layout.addWidget(self.cancel_button) - layout.addLayout(button_layout) + self.bb = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self) + bb.rejected.connect(self.reject) + self.authorize_button = b = bb.addButton(_('Authorize'), QDialogButtonBox.ButtonRole.ActionRole) + b.setIcon(QIcon.ic('drm-unlocked.png')) + b.clicked.connect(self.start_oauth_flow) + b.setDefault(True) + layout.addStretch(10) + layout.addWidget(bb) + s.setCurrentIndex(0) def get_selected_provider(self): return self.provider if self.provider_detected else self.provider_combo.currentData() - def start_oauth_flow(self): - provider = self.get_selected_provider() - self.authorize_button.setEnabled(False) - self.cancel_button.setEnabled(False) - self.status_label.setText(_('Starting authorization...')) + def run_flow(self, provider): + tokens, err = None, '' + try: + from calibre.utils.oauth2 import start_oauth_flow + tokens = start_oauth_flow(provider) + except Exception as e: + err = str(e) + if not sip.isdeleted(self): + self.flow_finished.emit(tokens, err) - progress = QProgressDialog( - _('Waiting for authorization in browser...\n\nThis dialog will close automatically when done.'), - _('Cancel'), 0, 0, self) - progress.setWindowTitle(_('OAuth Authorization')) - progress.setWindowModality(Qt.WindowModality.WindowModal) - progress.setMinimumDuration(0) - progress.setValue(0) + def start_oauth_flow(self): + self.stack.setCurrentIndex(1) + self.authorize_button.setEnabled(False) + self.status_label.setText('

' + + _('Waiting for authorization in browser...') + '

' + _( + 'This window will close automatically if authorization succeeds.')) + provider = self.get_selected_provider() from threading import Thread + Thread(target=partial(self.run_flow, provider), daemon=True).start() - class FlowCompleteEvent(QEvent): - EVENT_TYPE = QEvent.Type(QEvent.registerEventType()) - def __init__(self, success, error_msg): - super().__init__(self.EVENT_TYPE) - self.success = success - self.error_msg = error_msg - - self.FlowCompleteEvent = FlowCompleteEvent - - def run_flow(): - try: - from calibre.utils.oauth2 import OAuth2Error - from calibre.utils.oauth2 import start_oauth_flow as do_oauth_flow - self.tokens = do_oauth_flow(provider) - QApplication.instance().postEvent(self, FlowCompleteEvent(True, None)) - except OAuth2Error as e: - QApplication.instance().postEvent(self, FlowCompleteEvent(False, str(e))) - except Exception as e: - QApplication.instance().postEvent(self, FlowCompleteEvent(False, str(e))) - - def handle_flow_complete(event): - progress.close() - if event.success: - self.status_label.setText(_('Authorization successful!')) - info_dialog(self, _('Success'), _('OAuth 2.0 authorization was successful!'), show=True) - self.accept() - else: - self.status_label.setText(_('Authorization failed')) - self.authorize_button.setEnabled(True) - self.cancel_button.setEnabled(True) - error_dialog(self, _('Authorization Failed'), - _('OAuth 2.0 authorization failed:\n\n{error}').format(error=event.error_msg or _('Unknown error')), show=True) - - self.handle_flow_complete = handle_flow_complete - - def cancel_flow(): - progress.close() - self.status_label.setText(_('Authorization cancelled')) + def on_flow_finish(self, tokens, error_msg): + self.tokens = tokens + if error_msg: + error_dialog(self, _('Authorization Failed'), + _('OAuth 2.0 authorization failed. Click "Show details" for details.'), det_msg=error_msg, show=True) + self.stack.setCurrentIndex(0) self.authorize_button.setEnabled(True) - self.cancel_button.setEnabled(True) + self.activateWindow() + self.raise_() + else: + self.accept() + if parent := self.parent(): + parent.activateWindow() + parent.raise_() - progress.canceled.connect(cancel_flow) - Thread(target=run_flow, daemon=True).start() - - def event(self, e): - if hasattr(self, 'FlowCompleteEvent') and isinstance(e, self.FlowCompleteEvent): - self.handle_flow_complete(e) - return True - return super().event(e) + def reject(self): + self.flow_finished.disconnect() + super().reject() def get_tokens(self): return self.tokens diff --git a/src/calibre/utils/oauth2.py b/src/calibre/utils/oauth2.py index 5a0d76c7a6..ba0a9e8360 100644 --- a/src/calibre/utils/oauth2.py +++ b/src/calibre/utils/oauth2.py @@ -10,7 +10,6 @@ import base64 import hashlib import json import os -import socket import threading import time import webbrowser @@ -62,17 +61,6 @@ def generate_xoauth2_string(email, access_token): return base64.b64encode(auth_string.encode('utf-8')).decode('utf-8') -def find_available_port(start_port=8080, max_attempts=10): - for port in range(start_port, start_port + max_attempts): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(('localhost', port)) - return port - except OSError: - continue - return None - - class OAuth2Provider: name = None @@ -304,16 +292,12 @@ class OAuth2BrowserFlow: self.start_port = start_port def start_authorization_flow(self, timeout=300): - port = find_available_port(start_port=self.start_port) - if port is None: - raise OAuth2Error(f'No available port for OAuth callback (tried {self.start_port}-{self.start_port + 9})') - - self.provider.redirect_uri = f'http://localhost:{port}/oauth2callback' + server = HTTPServer(('localhost', 0), OAuth2CallbackHandler) + self.provider.redirect_uri = f'http://localhost:{server.server_port}/oauth2callback' code_verifier, code_challenge = generate_pkce_pair() state = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=') auth_url = self.provider.get_authorization_url(state, code_challenge) - server = HTTPServer(('localhost', port), OAuth2CallbackHandler) server.oauth_error = None server.authorization_code = None server.state = None