mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:17:58 +02:00
361 lines
13 KiB
Python
361 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Nexus OAuth Service
|
|
Handles OAuth 2.0 authentication flow with Nexus Mods using PKCE
|
|
"""
|
|
|
|
import os
|
|
import base64
|
|
import hashlib
|
|
import secrets
|
|
import webbrowser
|
|
import urllib.parse
|
|
import requests
|
|
import json
|
|
import threading
|
|
import logging
|
|
import time
|
|
import subprocess
|
|
from typing import Optional, Tuple, Dict
|
|
|
|
from .nexus_oauth_protocol import NexusOAuthProtocolMixin
|
|
from .nexus_oauth_callback import NexusOAuthCallbackMixin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin):
|
|
"""
|
|
Handles OAuth 2.0 authentication with Nexus Mods
|
|
Uses PKCE flow with system browser and localhost callback
|
|
"""
|
|
|
|
# OAuth Configuration
|
|
CLIENT_ID = "jackify"
|
|
AUTH_URL = "https://users.nexusmods.com/oauth/authorize"
|
|
TOKEN_URL = "https://users.nexusmods.com/oauth/token"
|
|
USERINFO_URL = "https://users.nexusmods.com/oauth/userinfo"
|
|
SCOPES = "public openid profile"
|
|
|
|
# Redirect configuration (custom protocol scheme - no SSL cert needed!)
|
|
# Requires jackify:// protocol handler to be registered with OS
|
|
REDIRECT_URI = "jackify://oauth/callback"
|
|
|
|
# Callback timeout (5 minutes)
|
|
CALLBACK_TIMEOUT = 300
|
|
|
|
def __init__(self):
|
|
"""Initialize OAuth service"""
|
|
self._auth_code = None
|
|
self._auth_state = None
|
|
self._auth_error = None
|
|
self._server_done = threading.Event()
|
|
|
|
# Ensure jackify:// protocol is registered on first use
|
|
self._ensure_protocol_registered()
|
|
|
|
def _generate_pkce_params(self) -> Tuple[str, str, str]:
|
|
"""
|
|
Generate PKCE code verifier, challenge, and state
|
|
|
|
Returns:
|
|
Tuple of (code_verifier, code_challenge, state)
|
|
"""
|
|
# Generate code verifier (43-128 characters, base64url encoded)
|
|
code_verifier = base64.urlsafe_b64encode(
|
|
os.urandom(32)
|
|
).decode('utf-8').rstrip('=')
|
|
|
|
# Generate code challenge (SHA256 hash of verifier, base64url encoded)
|
|
code_challenge = base64.urlsafe_b64encode(
|
|
hashlib.sha256(code_verifier.encode('utf-8')).digest()
|
|
).decode('utf-8').rstrip('=')
|
|
|
|
# Generate state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
|
|
return code_verifier, code_challenge, state
|
|
|
|
def _build_authorization_url(self, code_challenge: str, state: str) -> str:
|
|
"""
|
|
Build the Nexus OAuth 2.0 authorisation URL with PKCE parameters.
|
|
"""
|
|
params = {
|
|
"response_type": "code",
|
|
"client_id": self.CLIENT_ID,
|
|
"redirect_uri": self.REDIRECT_URI,
|
|
"scope": self.SCOPES,
|
|
"state": state,
|
|
"code_challenge": code_challenge,
|
|
"code_challenge_method": "S256",
|
|
}
|
|
query = urllib.parse.urlencode(params)
|
|
return f"{self.AUTH_URL}?{query}"
|
|
|
|
def _send_desktop_notification(self, title: str, message: str) -> None:
|
|
"""Send a desktop notification via notify-send (Linux). No-op on failure."""
|
|
try:
|
|
subprocess.run(
|
|
["notify-send", title, message],
|
|
capture_output=True,
|
|
timeout=5,
|
|
env={k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "PYTHONPATH", "QT_PLUGIN_PATH")},
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
logger.debug("Desktop notification skipped: %s", e)
|
|
except Exception as e:
|
|
logger.debug("Desktop notification failed: %s", e)
|
|
|
|
def _exchange_code_for_token(
|
|
self,
|
|
auth_code: str,
|
|
code_verifier: str
|
|
) -> Optional[Dict]:
|
|
"""
|
|
Exchange authorization code for access token
|
|
|
|
Args:
|
|
auth_code: Authorization code from callback
|
|
code_verifier: PKCE code verifier
|
|
|
|
Returns:
|
|
Token response dict or None on failure
|
|
"""
|
|
data = {
|
|
'grant_type': 'authorization_code',
|
|
'client_id': self.CLIENT_ID,
|
|
'redirect_uri': self.REDIRECT_URI,
|
|
'code': auth_code,
|
|
'code_verifier': code_verifier
|
|
}
|
|
|
|
try:
|
|
response = requests.post(self.TOKEN_URL, data=data, timeout=10)
|
|
|
|
if response.status_code == 200:
|
|
token_data = response.json()
|
|
logger.info("Successfully exchanged authorization code for token")
|
|
return token_data
|
|
else:
|
|
logger.error(f"Token exchange failed: {response.status_code} - {response.text}")
|
|
return None
|
|
|
|
except requests.RequestException as e:
|
|
logger.error(f"Token exchange request failed: {e}")
|
|
return None
|
|
|
|
def refresh_token(self, refresh_token: str) -> Optional[Dict]:
|
|
"""
|
|
Refresh an access token using refresh token
|
|
|
|
Args:
|
|
refresh_token: Refresh token from previous authentication
|
|
|
|
Returns:
|
|
New token response dict or None on failure
|
|
"""
|
|
data = {
|
|
'grant_type': 'refresh_token',
|
|
'client_id': self.CLIENT_ID,
|
|
'refresh_token': refresh_token
|
|
}
|
|
|
|
try:
|
|
response = requests.post(self.TOKEN_URL, data=data, timeout=10)
|
|
|
|
if response.status_code == 200:
|
|
token_data = response.json()
|
|
logger.info("Successfully refreshed access token")
|
|
return token_data
|
|
else:
|
|
logger.error(f"Token refresh failed: {response.status_code} - {response.text}")
|
|
return None
|
|
|
|
except requests.RequestException as e:
|
|
logger.error(f"Token refresh request failed: {e}")
|
|
return None
|
|
|
|
def get_user_info(self, access_token: str) -> Optional[Dict]:
|
|
"""
|
|
Get user information using access token
|
|
|
|
Args:
|
|
access_token: OAuth access token
|
|
|
|
Returns:
|
|
User info dict or None on failure
|
|
"""
|
|
headers = {
|
|
'Authorization': f'Bearer {access_token}'
|
|
}
|
|
|
|
try:
|
|
response = requests.get(self.USERINFO_URL, headers=headers, timeout=10)
|
|
|
|
if response.status_code == 200:
|
|
user_info = response.json()
|
|
logger.info(f"Retrieved user info for: {user_info.get('name', 'unknown')}")
|
|
return user_info
|
|
else:
|
|
logger.error(f"User info request failed: {response.status_code}")
|
|
return None
|
|
|
|
except requests.RequestException as e:
|
|
logger.error(f"User info request failed: {e}")
|
|
return None
|
|
|
|
def authorize(self, show_browser_message_callback=None) -> Optional[Dict]:
|
|
"""
|
|
Perform full OAuth authorization flow
|
|
|
|
Args:
|
|
show_browser_message_callback: Optional callback to display message about browser opening
|
|
|
|
Returns:
|
|
Token response dict or None on failure
|
|
"""
|
|
logger.info("Starting Nexus OAuth authorization flow")
|
|
|
|
# Reset state
|
|
self._auth_code = None
|
|
self._auth_state = None
|
|
self._auth_error = None
|
|
self._server_done.clear()
|
|
|
|
# Generate PKCE parameters
|
|
code_verifier, code_challenge, state = self._generate_pkce_params()
|
|
logger.debug(f"Generated PKCE parameters (state: {state[:10]}...)")
|
|
|
|
# Build authorization URL
|
|
auth_url = self._build_authorization_url(code_challenge, state)
|
|
|
|
# Open browser
|
|
logger.info("Opening browser for authorisation")
|
|
|
|
try:
|
|
# When running from AppImage, we need to clean the environment to avoid
|
|
# library conflicts with system tools (xdg-open, kde-open, etc.)
|
|
import os
|
|
import subprocess
|
|
|
|
env = os.environ.copy()
|
|
|
|
# Remove AppImage-specific environment variables that can cause conflicts
|
|
# These variables inject AppImage's bundled libraries into child processes
|
|
appimage_vars = [
|
|
'LD_LIBRARY_PATH',
|
|
'PYTHONPATH',
|
|
'PYTHONHOME',
|
|
'QT_PLUGIN_PATH',
|
|
'QML2_IMPORT_PATH',
|
|
]
|
|
|
|
# Check if we're running from AppImage
|
|
if 'APPIMAGE' in env or 'APPDIR' in env:
|
|
logger.debug("Running from AppImage - cleaning environment for browser launch")
|
|
for var in appimage_vars:
|
|
if var in env:
|
|
del env[var]
|
|
logger.debug(f"Removed {var} from browser environment")
|
|
|
|
# Use Popen instead of run to avoid waiting for browser to close
|
|
# xdg-open may not return until the browser closes, which could be never
|
|
try:
|
|
process = subprocess.Popen(
|
|
['xdg-open', auth_url],
|
|
env=env,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True # Detach from parent process
|
|
)
|
|
# Give it a moment to fail if it's going to fail
|
|
import time
|
|
time.sleep(0.5)
|
|
|
|
# Check if process is still running or has exited successfully
|
|
poll_result = process.poll()
|
|
if poll_result is None:
|
|
# Process still running - browser is opening/open
|
|
logger.info("Browser opened successfully via xdg-open (process running)")
|
|
browser_opened = True
|
|
elif poll_result == 0:
|
|
# Process exited successfully
|
|
logger.info("Browser opened successfully via xdg-open (exit code 0)")
|
|
browser_opened = True
|
|
else:
|
|
# Process exited with error
|
|
logger.warning(f"xdg-open exited with code {poll_result}, trying webbrowser module")
|
|
if webbrowser.open(auth_url):
|
|
logger.info("Browser opened successfully via webbrowser module")
|
|
browser_opened = True
|
|
else:
|
|
logger.warning("webbrowser.open returned False")
|
|
browser_opened = False
|
|
except FileNotFoundError:
|
|
# xdg-open not found - try webbrowser module
|
|
logger.warning("xdg-open not found, trying webbrowser module")
|
|
if webbrowser.open(auth_url):
|
|
logger.info("Browser opened successfully via webbrowser module")
|
|
browser_opened = True
|
|
else:
|
|
logger.warning("webbrowser.open returned False")
|
|
browser_opened = False
|
|
except Exception as e:
|
|
logger.error(f"Error opening browser: {e}")
|
|
browser_opened = False
|
|
|
|
# Send desktop notification
|
|
self._send_desktop_notification(
|
|
"Jackify - Nexus Authorisation",
|
|
"Please check your browser to authorise Jackify"
|
|
)
|
|
|
|
# Show message via callback if provided (AFTER browser opens)
|
|
if show_browser_message_callback:
|
|
if browser_opened:
|
|
show_browser_message_callback(
|
|
"Browser opened for Nexus authorisation.\n\n"
|
|
"After clicking 'Authorize', your browser may ask to\n"
|
|
"open Jackify or show a popup blocker notification.\n\n"
|
|
"Please click 'Open' or 'Allow' to complete authorization."
|
|
)
|
|
else:
|
|
show_browser_message_callback(
|
|
f"Could not open browser automatically.\n\n"
|
|
f"Please open this URL manually:\n{auth_url}"
|
|
)
|
|
|
|
try:
|
|
# Wait for callback via jackify:// protocol
|
|
if not self._wait_for_callback():
|
|
return None
|
|
|
|
# Check for errors
|
|
if self._auth_error:
|
|
logger.error(f"Authorization failed: {self._auth_error}")
|
|
return None
|
|
|
|
if not self._auth_code:
|
|
logger.error("No authorization code received")
|
|
return None
|
|
|
|
# Verify state matches
|
|
if self._auth_state != state:
|
|
logger.error("State mismatch - possible CSRF attack")
|
|
return None
|
|
|
|
logger.info("Authorization code received, exchanging for token")
|
|
|
|
# Exchange code for token
|
|
token_data = self._exchange_code_for_token(self._auth_code, code_verifier)
|
|
|
|
if token_data:
|
|
logger.info("OAuth authorization flow completed successfully")
|
|
else:
|
|
logger.error("Failed to exchange authorization code for token")
|
|
|
|
return token_data
|
|
finally:
|
|
self._expected_oauth_state = None
|