#!/opt/local/bin/python3.14
#
# Copyright 2012 Google Inc.
# Copyright 2025 Aditya Garg
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#	http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import sys

if "--help" in sys.argv:
	print("""
Usage: git-credential-gmail [OPTIONS]

Options:
  --help                 * Show this help message and exit.
  --set-client           * Set the client details to use for authentication.
  --delete-client        * Delete the client details set using --set-client.
  --authenticate         * Authenticate with Gmail using OAuth2.
      --external-auth    * Authenticate using an external browser.
                           Use this option with --authenticate.
  --force-refresh-token  * Force refresh the access token.
  --delete-token         * Delete the credentials saved using --authenticate.

Description:
  This script allows you to authenticate with Gmail using OAuth2 and
  retrieve access tokens for use with IMAP, POP, and SMTP protocols.

Examples:
  Authenticate using the browser-based flow:
    git-credential-gmail --authenticate
""")
	sys.exit(0)

import http.server
import keyring
import os
import requests
import random
import threading
import time
import urllib.parse

# https://github.com/mozilla/releases-comm-central/blob/master/mailnews/base/src/OAuth2Providers.sys.mjs
ClientId_Thunderbird_Desktop = "406964657835-aq8lmia8j95dhl1a2bvharmfk3t1hgqj.apps.googleusercontent.com"
ClientSecret_Thunderbird_Desktop = "kSmqreRr0qwBWJgbf5Y-PjSU"
Redirect_URI_Thunderbird_Desktop = "http://localhost/"

# https://github.com/thunderbird/thunderbird-android/blob/main/app-thunderbird/src/release/kotlin/net/thunderbird/android/auth/TbOAuthConfigurationFactory.kt
ClientId_Thunderbird_Android = "560629489500-kta4qnt6vrf1bj8ljcohj7cvbbqguauf.apps.googleusercontent.com"
ClientSecret_Thunderbird_Android = None
Redirect_URI_Thunderbird_Android = "net.thunderbird.android:/oauth2redirect"

# https://github.com/thunderbird/thunderbird-android/blob/main/app-k9mail/src/release/kotlin/app/k9mail/auth/K9OAuthConfigurationFactory.kt
ClientId_K9_Mail = "262622259280-hhmh92rhklkg2k1tjil69epo0o9a12jm.apps.googleusercontent.com"
ClientSecret_K9_Mail = None
Redirect_URI_K9_Mail = "com.fsck.k9:/oauth2redirect"

# https://gitlab.gnome.org/GNOME/evolution-data-server/-/commit/7554d3b95124486ac98d9a5052e069e46242a216
ClientId_Evolution = "590402290962-2i0b7rqma8b9nmtfrcp7fa06g6cf7g74.apps.googleusercontent.com"
ClientSecret_Evolution = "mtfUe5W8Aal9DcgVipOY1T9G"
Redirect_URI_Evolution = "http://localhost/"

# https://gitlab.gnome.org/GNOME/gnome-online-accounts/-/blob/master/meson_options.txt
ClientId_Gnome = "44438659992-7kgjeitenc16ssihbtdjbgguch7ju55s.apps.googleusercontent.com"
ClientSecret_Gnome = "-gMLuQyDiI0XrQS_vx_mhuYF"
Redirect_URI_Gnome = "http://localhost/"

# https://github.com/M66B/FairEmail/blob/master/app/src/main/res/xml/providers.xml
ClientId_FairEmail = "803253368361-11ias0ee6bqhvdi4f21fs1lh7fsb0il2.apps.googleusercontent.com"
ClientSecret_FairEmail = None
Redirect_URI_FairEmail = "eu.faircode.email:/"

Scopes = "https://mail.google.com/"
ServiceName = "git-credential-gmail"

def save_refresh_token(refresh_token):
	keyring.set_password(ServiceName, "refresh_token", refresh_token)

def load_refresh_token():
	return keyring.get_password(ServiceName, "refresh_token")

def delete_refresh_token():
	keyring.delete_password(ServiceName, "refresh_token")

def save_access_token(access_token, expiry):
	keyring.set_password(ServiceName, "access_token", access_token)
	keyring.set_password(ServiceName, "access_token_expiry", expiry)

def load_access_token():
	return keyring.get_password(ServiceName, "access_token")

def load_access_token_expiry():
	return keyring.get_password(ServiceName, "access_token_expiry")

def delete_access_token():
	keyring.delete_password(ServiceName, "access_token")
	keyring.delete_password(ServiceName, "access_token_expiry")

def print_access_token(access_token):
	if "get" in sys.argv:
		print(f"password={access_token}")
	else:
		print(access_token)

def set_client(client_id, client_secret, redirect_uri):
	keyring.set_password(ServiceName, "client_id", client_id)
	if client_secret:
		keyring.set_password(ServiceName, "client_secret", client_secret)
	else:
		try:
			delete_client_secret()
		except keyring.errors.PasswordDeleteError:
			pass
	keyring.set_password(ServiceName, "redirect_uri", redirect_uri)

def load_client_id():
	return keyring.get_password(ServiceName, "client_id")

def load_client_secret():
	return keyring.get_password(ServiceName, "client_secret")

def load_redirect_uri():
	return keyring.get_password(ServiceName, "redirect_uri")

def delete_client_id():
	keyring.delete_password(ServiceName, "client_id")

def delete_client_secret():
	keyring.delete_password(ServiceName, "client_secret")

def delete_redirect_uri():
	keyring.delete_password(ServiceName, "redirect_uri")

def qr_encode(url):
	try:
		try:
			import qrcode
			qr = qrcode.QRCode(border=2)
			qr.add_data(url)
			qr.make(fit=True)
			qr.print_ascii(invert=True)
		except Exception:
			headers = {'User-Agent': 'curl'}
			url_qr = f"https://qrenco.de/{url}"
			response = requests.get(url_qr, headers=headers)
			response.raise_for_status()
			content = response.text
			print(content)
	except Exception:
		# let's not interrupt the process if qrenco.de is down
		pass

def get_code_from_url(url):
	parsed_url = urllib.parse.urlparse(url)
	parsed_query = urllib.parse.parse_qs(parsed_url.query)
	return parsed_query.get('code', [''])[0]

def extract_redirect_scheme(url):
	scheme = urllib.parse.urlparse(url).scheme.lower()
	return scheme

def AccountsUrl(command):
	return '%s/%s' % ('https://accounts.google.com', command)

def UrlEscape(text):
	return urllib.parse.quote(text, safe='~-._')

def FormatUrlParams(params):
	param_fragments = []
	for param in sorted(params.items(), key=lambda x: x[0]):
		param_fragments.append('%s=%s' % (param[0], UrlEscape(param[1])))
	return '&'.join(param_fragments)

def GeneratePermissionUrl(redirect_uri):
	params = {}
	params['client_id'] = ClientId
	params['redirect_uri'] = redirect_uri
	params['scope'] = Scopes
	params['response_type'] = 'code'
	params['access_type'] = 'offline'
	params['prompt'] = 'consent'
	return '%s?%s' % (AccountsUrl('o/oauth2/auth'), FormatUrlParams(params))

def AuthorizeTokens(authorization_code):
	params = {}
	params['client_id'] = ClientId
	if ClientSecret:
		params['client_secret'] = ClientSecret
	params['code'] = authorization_code
	params['redirect_uri'] = Redirect_URI
	params['grant_type'] = 'authorization_code'
	request_url = AccountsUrl('o/oauth2/token')

	try:
		response = requests.post(request_url, data=params)
		response.raise_for_status()
		return response.json()
	except requests.exceptions.HTTPError:
		print(f"HTTP Error {response.status_code}: {response.reason}")
		print("Response Body:", response.text)
		sys.exit("\nFailed to get refresh token due to an HTTP error.")
	except Exception as e:
		print(f"Unexpected error: {e}")
		sys.exit("\nFailed to get refresh token due to an unexpected error.")

def RefreshToken(refresh_token):
	params = {}
	params['client_id'] = ClientId
	if ClientSecret:
		params['client_secret'] = ClientSecret
	params['refresh_token'] = refresh_token
	params['grant_type'] = 'refresh_token'
	request_url = AccountsUrl('o/oauth2/token')

	try:
		response = requests.post(request_url, data=params)
		response.raise_for_status()
		return response.json()
	except requests.exceptions.HTTPError:
		print(f"HTTP Error {response.status_code}: {response.reason}")
		print("Response Body:", response.text)
		sys.exit("\nFailed to get access token due to an HTTP error.\nTry running `git credential-gmail --authenticate` to get a new refresh token.")
	except Exception as e:
		print(f"Unexpected error: {e}")
		sys.exit("\nFailed to get access token due to an unexpected error.\nTry running `git credential-gmail --authenticate` to get a new refresh token.")

ClientId = load_client_id()
ClientSecret = load_client_secret()
Redirect_URI = load_redirect_uri()
if ClientId is None:
	ClientId = ClientId_Thunderbird_Desktop
	ClientSecret = ClientSecret_Thunderbird_Desktop
	Redirect_URI = Redirect_URI_Thunderbird_Desktop

elif Redirect_URI is None:
	Redirect_URI = "http://localhost/"

if "--set-client" in sys.argv:
	print("What client details do you want to use?\n")
	print("1. Thunderbird")
	print("2. K-9 Mail")
	print("3. FairEmail")
	print("4. GNOME Evolution")
	print("5. GNOME Online Accounts")
	print("6. Use your own custom client details")
	answer = input("\nType the number of your choice and press enter: ")

	if answer == "1":
		print("\nChoose any Thunderbird variant:\n")
		print("1. Thunderbird Desktop")
		print("2. Thunderbird Mobile")
		answer_alt = input("\nType the number of your choice and press enter: ")
		if answer_alt == "1":
			ClientId = ClientId_Thunderbird_Desktop
			ClientSecret = ClientSecret_Thunderbird_Desktop
			Redirect_URI = Redirect_URI_Thunderbird_Desktop
		elif answer_alt == "2":
			ClientId = ClientId_Thunderbird_Android
			ClientSecret = ClientSecret_Thunderbird_Android
			Redirect_URI = Redirect_URI_Thunderbird_Android
	elif answer == "2":
		ClientId = ClientId_K9_Mail
		ClientSecret = ClientSecret_K9_Mail
		Redirect_URI = Redirect_URI_K9_Mail
	elif answer == "3":
		ClientId = ClientId_FairEmail
		ClientSecret = ClientSecret_FairEmail
		Redirect_URI = Redirect_URI_FairEmail
	elif answer == "4":
		ClientId = ClientId_Evolution
		ClientSecret = ClientSecret_Evolution
		Redirect_URI = Redirect_URI_Evolution
	elif answer == "5":
		ClientId = ClientId_Gnome
		ClientSecret = ClientSecret_Gnome
		Redirect_URI = Redirect_URI_Gnome
	elif answer == "6":
		ClientId = input("\nEnter the Client ID: ")
		ClientSecret = input("Enter the Client Secret (leave blank if not applicable): ")
		Redirect_URI = input("Enter the Redirect URI: ")
	else:
		sys.exit("\nInvalid choice")

	if ClientSecret == "":
		ClientSecret = None

	set_client(ClientId, ClientSecret, Redirect_URI)
	print("\nClient details saved to keyring")

elif "--delete-client" in sys.argv:
	exit_error = 0
	try:
		delete_client_id()
		print("Client ID deleted from keyring")
	except keyring.errors.PasswordDeleteError:
		print("No Client ID found in keyring")
		exit_error = 1

	try:
		delete_client_secret()
		print("Client Secret deleted from keyring")
	except keyring.errors.PasswordDeleteError:
		print("No Client Secret found in keyring")
		exit_error = 1

	try:
		delete_redirect_uri()
		print("Redirect URI deleted from keyring")
	except keyring.errors.PasswordDeleteError:
		print("No Redirect URI found in keyring")
		exit_error = 1

	sys.exit(exit_error)
		
elif "--authenticate" in sys.argv:
	Redirect_URI_is_http = 'false'
	Create_local_server = 'false'
	scheme = extract_redirect_scheme(Redirect_URI)
	if "--external-auth" not in sys.argv and urllib.parse.urlparse(Redirect_URI).hostname == "localhost" and scheme == "http":
		random_port = random.randint(1024, 65535)
		Redirect_URI = f"http://localhost:{random_port}/"
		Create_local_server = 'true'
	if scheme == 'http' or scheme == 'https':
		Redirect_URI_is_http = 'true'

	url = GeneratePermissionUrl(Redirect_URI)
	code = ''
	if "--external-auth" in sys.argv:
		# Use external browser for authentication
		qr_encode(url)
		print("Navigate to the following URL in a web browser:\n")
		print(url)
		if Redirect_URI_is_http == 'true':
			print("\nAfter login, you will be redirected to a blank or error page.")
			codeurl = input("Copy the URL of that page and paste it here:\n")
		else:
			print("\nThis client uses a non-http redirect URI.")
			print("Before login enable network logging in your browser.")
			print("After login, check the network logs for a URL that:")
			print(f"    1. Starts with \"{scheme}:/\".")
			print("    2. Has \"code\" in it.")
			codeurl = input("Copy that URL and paste it here:\n")
		code = get_code_from_url(codeurl)
		print()
	else:
		print("Opening a browser window for authentication...\n")
		try:
			from PyQt6.QtCore import QUrl, QLoggingCategory
			from PyQt6.QtWidgets import QApplication, QMainWindow
			from PyQt6.QtWebEngineWidgets import QWebEngineView
			from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile, QWebEngineUrlSchemeHandler, QWebEngineUrlRequestJob, QWebEngineUrlScheme
			qt_available = True
		except Exception:
			try:
				from PySide6.QtCore import QUrl, QLoggingCategory
				from PySide6.QtWidgets import QApplication, QMainWindow
				from PySide6.QtWebEngineWidgets import QWebEngineView
				from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile, QWebEngineUrlSchemeHandler, QWebEngineUrlRequestJob, QWebEngineUrlScheme
				qt_available = True
			except Exception:
				import webbrowser
				qt_available = False
				webbrowser.open(url)

			if Create_local_server == 'true':
				class Handler(http.server.BaseHTTPRequestHandler):
					def log_message(self, format, *args):
						# Override to prevent logging to console
						pass
					def do_GET(self):
						global code
						code = get_code_from_url(self.path)
						response_body = b'Success. Look back at your terminal.\r\n'
						self.send_response(200)
						self.send_header('Content-Type', 'text/plain')
						self.send_header('Content-Length', len(response_body))
						self.end_headers()
						self.wfile.write(response_body)

						global httpd
						threading.Thread(target=lambda: httpd.shutdown()).start()

				server_address = ('', random_port)
				httpd = http.server.HTTPServer(server_address, Handler)

		if qt_available:
			if "--verbose" in sys.argv:
				loglevel = 0
			else:
				loglevel = 3
				QLoggingCategory("qt.webenginecontext").setFilterRules("*.info=false") # Suppress info logs

			os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = f"--enable-logging --log-level={loglevel}"

			def register_scheme(name: str):
				scheme = QWebEngineUrlScheme(name.encode())
				scheme.setFlags(
					QWebEngineUrlScheme.Flag.SecureScheme
					| QWebEngineUrlScheme.Flag.LocalAccessAllowed
				)
				QWebEngineUrlScheme.registerScheme(scheme)

			class CatchAllHandler(QWebEngineUrlSchemeHandler):

				def requestStarted(self, job: QWebEngineUrlRequestJob):
					job.fail(QWebEngineUrlRequestJob.Error.UrlInvalid)

			class Page(QWebEnginePage):

				if "--verbose" not in sys.argv:
					#js messages spam the console for no reason
					def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
						return

				def acceptNavigationRequest(self, url: QUrl, nav_type, is_main_frame: bool) -> bool:
					if "code=" in url.toString() and url.toString().startswith(Redirect_URI):
						global code
						code = get_code_from_url(url.toString())
						QApplication.instance().quit()
					return super().acceptNavigationRequest(url, nav_type, is_main_frame)

			class BrowserWindow(QMainWindow):

				def __init__(self, profile: QWebEngineProfile):
					super().__init__()
					self.setWindowTitle("OAuth2 Login")
					self.resize(800, 600)
					self.browser = QWebEngineView()
					self.browser.setPage(Page(profile, self.browser))
					self.setCentralWidget(self.browser)
					self.browser.load(QUrl(url))
					self.show()

			if Redirect_URI_is_http == 'false':
				register_scheme(scheme)
			webapp = QApplication(sys.argv)
			profile = QWebEngineProfile.defaultProfile()
			handler = CatchAllHandler()
			if Redirect_URI_is_http == 'false':
				profile.installUrlSchemeHandler(scheme.encode(), handler)
			window = BrowserWindow(profile)
			webapp.exec()

		else:
			if Create_local_server == 'true':
				httpd.serve_forever()
			else:
				print("Authenticate in the browser window that opened.")
				if Redirect_URI_is_http == 'true':
					print("\nAfter login, you will be redirected to a blank or error page.")
					codeurl = input("Copy the URL of that page and paste it here:\n")
				else:
					print("\nThis client uses a non-http redirect URI.")
					print("Before login enable network logging in your browser.")
					print("After login, check the network logs for a URL that:")
					print(f"    1. Starts with \"{scheme}:/\".")
					print("    2. Has \"code\" in it.")
					print("Copy that URL and paste it here:\n")
					codeurl = input("Alternatively cancel the process (Ctrl+C) and install either PyQt6-WebEngine or PySide6 for easier login.\n")
				code = get_code_from_url(codeurl)
				print()

	token = AuthorizeTokens(code)

	if 'error' in token:
		print(token)
		sys.exit("\nFailed to get refresh token")

	save_refresh_token(token['refresh_token'])
	try:
		delete_access_token()
	except keyring.errors.PasswordDeleteError:
		pass
	print('Saved refresh token to keyring')

elif "--delete-token" in sys.argv:
	exit_error = 0
	try:
		delete_refresh_token()
		print("Refresh token deleted from keyring")
	except keyring.errors.PasswordDeleteError:
		print("No refresh token found in keyring")
		exit_error = 1

	try:
		delete_access_token()
		print("Access token deleted from keyring")
	except keyring.errors.PasswordDeleteError:
		print("No access token found in keyring")
		exit_error = 1
	
	sys.exit(exit_error)

else:
	need_new_access_token = False
	access_token = load_access_token()
	access_token_expiry = load_access_token_expiry()

	if access_token is None or access_token_expiry is None or (int(access_token_expiry) - int(time.time())) <= 0 or "--force-refresh-token" in sys.argv:
		need_new_access_token = True

	if need_new_access_token:
		# Lets use the refresh token to get a new access token
		refresh_token = load_refresh_token()

		if refresh_token is None:
			sys.exit("No refresh token found.\nPlease authenticate first by running `git credential-gmail --authenticate`")

		token = RefreshToken(refresh_token)

		if 'error' in token:
			print(token)
			sys.exit("Failed to get access token from the server")
		else:
			access_token = token['access_token']
			access_token_expiry = int(time.time()) + int(token['expires_in']) - 120 # Subtract 2 minutes to ensure we never get an expired token
			try: # Some credential helpers hate such long access tokens (looking at you Windows). Still we can refresh the token right ;)
				save_access_token(access_token, str(access_token_expiry))
			except Exception:
				pass

	print_access_token(access_token)
