diff --git a/.claude/skills/git-fork-workflow/SKILL.md b/.claude/skills/git-fork-workflow/SKILL.md new file mode 100644 index 00000000..59fbd9cf --- /dev/null +++ b/.claude/skills/git-fork-workflow/SKILL.md @@ -0,0 +1,30 @@ +--- +name: git-fork-workflow +description: Git workflow for contributing to this repository via fork. Use when creating commits, branches, or pull requests. +--- + +# Git Fork Workflow + +This repository uses a fork-based contribution model: + +- **origin**: Points to the contributor's fork (e.g., `username/commcare-sync`) +- **upstream**: Points to the main repository (`dimagi/commcare-sync`) + +Contributors do NOT have write access to the main repository, only to their fork. + +## Creating Pull Requests + +1. Create a feature branch from the current branch +2. Make and commit changes +3. Push the branch to `origin` (your fork) +4. Create a PR targeting the upstream repo + +```bash +git checkout -b fix/descriptive-branch-name +git add +git commit -m "Commit message" +git push -u origin fix/descriptive-branch-name +gh pr create --repo dimagi/commcare-sync --title "PR Title" --body "Description" +``` + +Never attempt to push directly to `dimagi/commcare-sync` - use PRs from your fork. diff --git a/.env.dev b/.env.dev index f11ab736..135f72d5 100644 --- a/.env.dev +++ b/.env.dev @@ -9,3 +9,18 @@ DJANGO_SETTINGS_MODULE=commcare_sync.settings_docker # Use a Docker-specific virtualenv directory to avoid conflicts with host .venv UV_PROJECT_ENVIRONMENT=.venv-docker + +# CommCare OAuth Configuration +# See docs for setup instructions: create two OAuth apps in CommCare HQ +# 1. Web App (Confidential Client) - for browser-based OAuth flow +# 2. CLI Tool (Public Client) - for command-line token acquisition + +# CommCare HQ URL (defaults to production if not set) +# COMMCARE_HQ_URL=https://www.commcarehq.org + +# Web OAuth App (Confidential Client) +# COMMCARE_OAUTH_CLIENT_ID=your_web_client_id +# COMMCARE_OAUTH_CLIENT_SECRET=your_web_client_secret + +# CLI OAuth App (Public Client - no secret needed, uses PKCE) +# COMMCARE_OAUTH_CLI_CLIENT_ID=your_cli_client_id diff --git a/.gitignore b/.gitignore index 54c60b9c..a7d52832 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ static_root/ # uv .venv/ + +#configuration +.env diff --git a/AGENTS.md b/AGENTS.md index beff6a4b..ab5e732a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,3 +17,7 @@ See [pyproject.toml](pyproject.toml). ## Coding Style See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Skills + +When creating AI skills for this project, add them to `.claude/skills/`. This format is compatible with both Cursor and Claude Code. diff --git a/apps/commcare/management/__init__.py b/apps/commcare/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/commcare/management/commands/__init__.py b/apps/commcare/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/commcare/management/commands/get_commcare_token.py b/apps/commcare/management/commands/get_commcare_token.py new file mode 100644 index 00000000..99c62448 --- /dev/null +++ b/apps/commcare/management/commands/get_commcare_token.py @@ -0,0 +1,141 @@ +""" +Django management command to obtain CommCare OAuth token via CLI flow. + +Usage: + python manage.py get_commcare_token + +Or with custom settings: + python manage.py get_commcare_token --port 8888 + +This uses a public OAuth client (no secret) with PKCE for security. +Tokens are saved to ~/.commcare-sync/commcare_token.json +""" + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from apps.commcare.oauth.cli import TokenManager, get_oauth_token +from apps.commcare.oauth.utils import fetch_user_identity + + +class Command(BaseCommand): + help = 'Obtain a CommCare OAuth access token for CLI/script usage via browser authorization' + + def add_arguments(self, parser): + parser.add_argument( + '--client-id', + type=str, + help='OAuth client ID (defaults to COMMCARE_OAUTH_CLI_CLIENT_ID from settings)', + ) + parser.add_argument( + '--commcare-url', + type=str, + help='CommCare HQ URL (defaults to COMMCARE_HQ_URL from settings)', + ) + parser.add_argument( + '--port', + type=int, + default=8765, + help='Local port for OAuth callback (default: 8765)', + ) + parser.add_argument( + '--scope', + type=str, + default='access_apis', + help='OAuth scopes to request (default: "access_apis")', + ) + parser.add_argument( + '--save-to', + type=str, + help='Save token to specified file instead of default location', + ) + parser.add_argument( + '--quiet', + action='store_true', + help='Suppress output (only print token)', + ) + + def handle(self, *args, **options): + # Get configuration from options or settings + client_id = options.get('client_id') or getattr( + settings, 'COMMCARE_OAUTH_CLI_CLIENT_ID', None + ) + commcare_url = options.get('commcare_url') or getattr( + settings, 'COMMCARE_HQ_URL', 'https://www.commcarehq.org' + ) + + if not client_id: + raise CommandError( + 'OAuth client ID not provided.\n' + 'Set COMMCARE_OAUTH_CLI_CLIENT_ID in settings/environment or use --client-id\n\n' + 'To set up OAuth:\n' + '1. Create a Public OAuth application in CommCare HQ\n' + '2. Set redirect URI to: http://localhost:8765/callback\n' + '3. Add COMMCARE_OAUTH_CLI_CLIENT_ID to your .env file' + ) + + if not options['quiet']: + self.stdout.write( + self.style.SUCCESS('\nCommCare OAuth Token Setup') + ) + self.stdout.write('=' * 70) + self.stdout.write(f'CommCare URL: {commcare_url}') + self.stdout.write(f'Client ID: {client_id}') + self.stdout.write(f'Scope: {options["scope"]}') + self.stdout.write(f'Callback Port: {options["port"]}\n') + + # Get OAuth token + token_data = get_oauth_token( + client_id=client_id, + commcare_url=commcare_url, + port=options['port'], + scope=options['scope'], + verbose=not options['quiet'], + ) + + if not token_data: + raise CommandError('Failed to obtain OAuth token') + + # Fetch user identity + access_token = token_data.get('access_token') + user_identity = None + if access_token: + user_identity = fetch_user_identity(access_token) + if user_identity and not options['quiet']: + self.stdout.write( + self.style.SUCCESS( + f'Authenticated as: {user_identity.get("username", "unknown")}' + ) + ) + + # Save to token manager + if options.get('save_to'): + token_manager = TokenManager(token_file=options['save_to']) + else: + token_manager = TokenManager() + + if token_manager.save_token(token_data, user_identity): + if not options['quiet']: + self.stdout.write( + self.style.SUCCESS( + f'\nToken saved to: {token_manager.token_file}' + ) + ) + else: + self.stderr.write(self.style.ERROR('Failed to save token')) + + # Show token info + info = token_manager.get_token_info() + if info and 'expires_in_seconds' in info and not options['quiet']: + minutes = info['expires_in_seconds'] // 60 + self.stdout.write(f'Expires in: {minutes} minutes\n') + + if not options['quiet']: + self.stdout.write(self.style.SUCCESS('Setup Complete!')) + self.stdout.write( + 'You can now run: python manage.py test_oauth_connection\n' + ) + + # In quiet mode, just print the token + if options['quiet']: + self.stdout.write(access_token) diff --git a/apps/commcare/management/commands/test_oauth_connection.py b/apps/commcare/management/commands/test_oauth_connection.py new file mode 100644 index 00000000..d7894cc0 --- /dev/null +++ b/apps/commcare/management/commands/test_oauth_connection.py @@ -0,0 +1,160 @@ +""" +Django management command to test CommCare OAuth connection. + +Usage: + python manage.py test_oauth_connection + +Tests: + 1. Token file exists and is readable + 2. Token is not expired + 3. Token can authenticate with CommCare identity API + 4. User has access to at least one domain +""" + +from django.core.management.base import BaseCommand, CommandError + +from apps.commcare.oauth.cli import TokenManager +from apps.commcare.oauth.utils import ( + fetch_user_domains, + fetch_user_identity, + get_commcare_hq_url, +) + + +class Command(BaseCommand): + help = 'Test CommCare OAuth connection and token validity' + + def add_arguments(self, parser): + parser.add_argument( + '--token-file', + type=str, + help='Path to token file (defaults to ~/.commcare-sync/commcare_token.json)', + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Show detailed output', + ) + + def handle(self, *args, **options): + verbose = options.get('verbose', False) + token_file = options.get('token_file') + + self.stdout.write('\nCommCare OAuth Connection Test') + self.stdout.write('=' * 70) + + # Initialize token manager + token_manager = TokenManager(token_file=token_file) + + # Test 1: Token file exists + self.stdout.write('\n1. Checking token file...') + token_data = token_manager.load_token() + if not token_data: + self.stdout.write( + self.style.ERROR( + f' [FAIL] No token found at {token_manager.token_file}' + ) + ) + self.stdout.write('\n Run: python manage.py get_commcare_token') + raise CommandError('No token file found') + self.stdout.write( + self.style.SUCCESS( + f' [OK] Token loaded from {token_manager.token_file}' + ) + ) + + # Test 2: Token not expired + self.stdout.write('\n2. Checking token expiration...') + info = token_manager.get_token_info() + if not info: + self.stdout.write( + self.style.ERROR(' [FAIL] Could not read token info') + ) + raise CommandError('Invalid token data') + + if not info.get('is_valid'): + self.stdout.write(self.style.ERROR(' [FAIL] Token is expired')) + self.stdout.write('\n Run: python manage.py get_commcare_token') + raise CommandError('Token expired') + + expires_in = info.get('expires_in_seconds', 0) + minutes = expires_in // 60 + self.stdout.write( + self.style.SUCCESS( + f' [OK] Token is valid (expires in {minutes} minutes)' + ) + ) + + if verbose: + self.stdout.write( + f' Saved at: {info.get("saved_at", "unknown")}' + ) + self.stdout.write( + f' Expires at: {info.get("expires_at", "unknown")}' + ) + self.stdout.write( + f' Has refresh token: {info.get("has_refresh_token", False)}' + ) + + # Test 3: Token authenticates with CommCare + self.stdout.write('\n3. Testing CommCare identity API...') + access_token = token_manager.get_valid_token() + if not access_token: + self.stdout.write( + self.style.ERROR(' [FAIL] Could not get valid access token') + ) + raise CommandError('Invalid access token') + + commcare_url = get_commcare_hq_url() + identity = fetch_user_identity(access_token) + if not identity: + self.stdout.write( + self.style.ERROR(' [FAIL] Identity API request failed') + ) + self.stdout.write(f' CommCare URL: {commcare_url}') + raise CommandError('Identity API failed') + + username = identity.get('username', 'unknown') + self.stdout.write( + self.style.SUCCESS(f' [OK] Authenticated as: {username}') + ) + + if verbose: + self.stdout.write(f' CommCare URL: {commcare_url}') + + # Test 4: Check domain access + self.stdout.write('\n4. Checking domain access...') + domains = fetch_user_domains(access_token) + if domains is None: + self.stdout.write( + self.style.ERROR(' [FAIL] Could not fetch user domains') + ) + raise CommandError('User domains API failed') + elif not domains: + self.stdout.write( + self.style.WARNING(' [WARN] User has no domain access') + ) + else: + self.stdout.write( + self.style.SUCCESS( + f' [OK] User has access to {len(domains)} domain(s)' + ) + ) + if verbose or len(domains) <= 5: + for domain_info in domains[:10]: + domain_name = domain_info.get('domain_name', 'unknown') + project_name = domain_info.get('project_name', domain_name) + if domain_name != project_name: + self.stdout.write(f' - {domain_name} ({project_name})') + else: + self.stdout.write(f' - {domain_name}') + if len(domains) > 10: + self.stdout.write( + f' ... and {len(domains) - 10} more' + ) + + # Summary + self.stdout.write('\n' + '=' * 70) + self.stdout.write(self.style.SUCCESS('All tests passed!')) + self.stdout.write(f'\nOAuth connection is working. User: {username}') + self.stdout.write(f'Token expires in {minutes} minutes.\n') diff --git a/apps/commcare/oauth/__init__.py b/apps/commcare/oauth/__init__.py new file mode 100644 index 00000000..440786b1 --- /dev/null +++ b/apps/commcare/oauth/__init__.py @@ -0,0 +1,22 @@ +""" +CommCare OAuth submodule for configuration assistance. + +This module provides OAuth integration with CommCare HQ to enable +easier configuration of CommCare Sync. OAuth tokens are stored in +the session (not database) since they're only used for interactive +user-driven configuration, not for production exports. + +Production exports continue to use API keys stored in CommCareAccount. +""" + +from apps.commcare.oauth.utils import ( + generate_pkce_pair, + get_commcare_oauth_session, + has_valid_oauth_token, +) + +__all__ = [ + 'generate_pkce_pair', + 'get_commcare_oauth_session', + 'has_valid_oauth_token', +] diff --git a/apps/commcare/oauth/cli/__init__.py b/apps/commcare/oauth/cli/__init__.py new file mode 100644 index 00000000..72731c30 --- /dev/null +++ b/apps/commcare/oauth/cli/__init__.py @@ -0,0 +1,14 @@ +""" +CLI tools for CommCare OAuth token management. + +Provides browser-based OAuth flow for command-line tools and scripts. +Tokens are stored locally in ~/.commcare-sync/commcare_token.json +""" + +from apps.commcare.oauth.cli.client import get_oauth_token +from apps.commcare.oauth.cli.token_manager import TokenManager + +__all__ = [ + 'TokenManager', + 'get_oauth_token', +] diff --git a/apps/commcare/oauth/cli/client.py b/apps/commcare/oauth/cli/client.py new file mode 100644 index 00000000..eb7d945f --- /dev/null +++ b/apps/commcare/oauth/cli/client.py @@ -0,0 +1,294 @@ +""" +OAuth CLI Client for CommCare HQ. + +Implements the OAuth Authorization Code flow with PKCE for CLI tools. +This allows scripts to authenticate users via browser and obtain access tokens. +""" + +import socket +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlencode, urlparse + +import httpx + +from apps.commcare.oauth.utils import ( + fetch_user_identity, + generate_pkce_pair, + get_commcare_hq_url, + get_oauth_token_url, +) + + +class OAuthCallbackHandler(BaseHTTPRequestHandler): + """HTTP handler that captures OAuth callback with authorization code.""" + + received_code = None + received_error = None + received_error_description = None + + def do_GET(self): + """Handle GET request from OAuth provider redirect.""" + query = parse_qs(urlparse(self.path).query) + + # Capture authorization code or error + OAuthCallbackHandler.received_code = query.get('code', [None])[0] + OAuthCallbackHandler.received_error = query.get('error', [None])[0] + OAuthCallbackHandler.received_error_description = query.get( + 'error_description', [None] + )[0] + + # Send response to browser + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + if OAuthCallbackHandler.received_code: + html = """ + +

[SUCCESS] Authorization Successful!

+

You can close this window and return to your terminal.

+ + + """ + else: + error_msg = ( + OAuthCallbackHandler.received_error_description + or OAuthCallbackHandler.received_error + or 'Unknown error' + ) + html = f""" + +

[ERROR] Authorization Failed

+

Error: {error_msg}

+

Please check the terminal for details.

+ + """ + + self.wfile.write(html.encode('utf-8')) + + def log_message(self, format, *args): + """Suppress HTTP server logs.""" + pass + + +def is_port_available(port: int) -> bool: + """Check if a port is available for binding.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('localhost', port)) + return True + except OSError: + return False + + +def get_oauth_token( + client_id: str, + commcare_url: str | None = None, + port: int = 8765, + callback_path: str = '/callback', + scope: str = 'access_apis', + verbose: bool = True, +) -> dict | None: + """ + Obtain an OAuth access token via browser-based authorization. + + This implements the OAuth Authorization Code flow with PKCE. It: + 1. Starts a local HTTP server to receive the callback + 2. Opens the user's browser to the authorization page + 3. Waits for the user to authorize + 4. Exchanges the authorization code for an access token + + Args: + client_id: OAuth client ID (use CLI public client) + commcare_url: CommCare HQ base URL (defaults to settings) + port: Local port for OAuth callback (default: 8765) + callback_path: Path for OAuth callback (default: "/callback") + scope: OAuth scopes to request (default: "access_apis") + verbose: Print status messages (default: True) + + Returns: + Dict with token data including 'access_token', 'token_type', 'expires_in', etc. + Returns None if authorization fails. + """ + if commcare_url is None: + commcare_url = get_commcare_hq_url() + + redirect_uri = f'http://localhost:{port}{callback_path}' + + # Check if port is available + if not is_port_available(port): + if verbose: + print(f'Error: Port {port} is already in use.') + print( + 'Please close the application using it or use --port to choose a different port.' + ) + return None + + # Reset handler state + OAuthCallbackHandler.received_code = None + OAuthCallbackHandler.received_error = None + OAuthCallbackHandler.received_error_description = None + + # Generate PKCE values for security + code_verifier, code_challenge = generate_pkce_pair() + + # Build authorization URL + auth_params = { + 'client_id': client_id, + 'redirect_uri': redirect_uri, + 'response_type': 'code', + 'scope': scope, + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256', + } + auth_url = f'{commcare_url}/oauth/authorize/?{urlencode(auth_params)}' + + if verbose: + print() + print('=' * 70) + print('CommCare OAuth Authorization Flow') + print('=' * 70) + print() + print(f'CommCare URL: {commcare_url}') + print(f'Client ID: {client_id}') + print() + print('Opening browser for authorization...') + print('If browser does not open, visit this URL:') + print(auth_url) + print() + print('Waiting for authorization...') + + # Open browser for user authorization + webbrowser.open(auth_url) + + # Start local server and wait for callback + server = HTTPServer(('localhost', port), OAuthCallbackHandler) + server.handle_request() + + # Check if we received an authorization code + if OAuthCallbackHandler.received_error: + if verbose: + error_msg = ( + OAuthCallbackHandler.received_error_description + or OAuthCallbackHandler.received_error + ) + print(f'\n[ERROR] Authorization failed: {error_msg}') + return None + + if not OAuthCallbackHandler.received_code: + if verbose: + print('\n[ERROR] No authorization code received') + return None + + if verbose: + print('\n[OK] Authorization code received') + print('Exchanging code for access token...') + + # Exchange authorization code for access token + token_data = { + 'grant_type': 'authorization_code', + 'code': OAuthCallbackHandler.received_code, + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'code_verifier': code_verifier, + } + + try: + response = httpx.post( + get_oauth_token_url(), + data=token_data, + timeout=30, + ) + response.raise_for_status() + token_response = response.json() + + if verbose: + print('\n[OK] Successfully obtained OAuth token!') + print('=' * 70) + print(f'\nAccess Token: {token_response["access_token"][:20]}...') + print(f'Token Type: {token_response.get("token_type", "Bearer")}') + print( + f'Expires In: {token_response.get("expires_in", "Unknown")} seconds' + ) + if token_response.get('refresh_token'): + print('Refresh Token: Available') + print() + + return token_response + + except httpx.HTTPStatusError as e: + if verbose: + print(f'\n[ERROR] Token exchange failed: {e.response.status_code}') + print(f'Response: {e.response.text}') + return None + except Exception as e: + if verbose: + print(f'\n[ERROR] Error exchanging token: {str(e)}') + return None + + +def get_or_refresh_token( + client_id: str, + commcare_url: str | None = None, + token_file: str | None = None, + verbose: bool = True, +) -> str | None: + """ + Get a valid token, fetching a new one if needed. + + This is a convenience function that: + 1. Checks for existing valid token + 2. Returns it if valid + 3. Fetches new token via OAuth flow if expired/missing + + Args: + client_id: OAuth client ID + commcare_url: CommCare HQ base URL + token_file: Optional custom token file path + verbose: Print status messages + + Returns: + Valid access token or None if failed + """ + from apps.commcare.oauth.cli.token_manager import TokenManager + + manager = TokenManager(token_file) + + # Try to get existing valid token + token = manager.get_valid_token() + + if token: + if verbose: + info = manager.get_token_info() + if info and 'expires_in_seconds' in info: + minutes = info['expires_in_seconds'] // 60 + print(f'Using cached token (expires in {minutes} minutes)') + return token + + # Need new token + if verbose: + print('No valid token found. Starting OAuth flow...') + + token_data = get_oauth_token( + client_id=client_id, + commcare_url=commcare_url, + verbose=verbose, + ) + + if not token_data: + return None + + # Fetch user identity + access_token = token_data.get('access_token') + user_identity = None + if access_token: + user_identity = fetch_user_identity(access_token) + + # Save for future use + manager.save_token(token_data, user_identity) + + if verbose: + print(f'Token saved to: {manager.token_file}') + + return access_token diff --git a/apps/commcare/oauth/cli/token_manager.py b/apps/commcare/oauth/cli/token_manager.py new file mode 100644 index 00000000..395e047f --- /dev/null +++ b/apps/commcare/oauth/cli/token_manager.py @@ -0,0 +1,187 @@ +""" +Token Manager for CommCare OAuth CLI tokens. + +Handles secure storage, loading, and validation of OAuth tokens for CLI usage. +Tokens are stored in ~/.commcare-sync/commcare_token.json +""" + +import json +import os +import stat +from datetime import datetime, timedelta +from pathlib import Path + + +class TokenManager: + """ + Manages OAuth token storage and retrieval for CLI tools. + + Tokens are stored in JSON format with expiration tracking. + """ + + def __init__(self, token_file: str | None = None): + """ + Initialize token manager. + + Args: + token_file: Path to token file. Defaults to ~/.commcare-sync/commcare_token.json + """ + if token_file: + self.token_file = Path(token_file) + else: + # Default: Store in user's home directory + config_dir = Path.home() / '.commcare-sync' + config_dir.mkdir(exist_ok=True) + self.token_file = config_dir / 'commcare_token.json' + + def save_token( + self, token_data: dict, user_identity: dict | None = None + ) -> bool: + """ + Save OAuth token to file with expiration timestamp. + + Args: + token_data: Token response from OAuth provider + user_identity: Optional user identity dict from CommCare + + Returns: + True if successful, False otherwise + """ + try: + # Calculate expiration time if expires_in is provided + if 'expires_in' in token_data: + expires_at = ( + datetime.now() + + timedelta(seconds=token_data['expires_in']) + ).isoformat() + token_data['expires_at'] = expires_at + + # Add saved timestamp + token_data['saved_at'] = datetime.now().isoformat() + + # Add user identity if provided + if user_identity: + token_data['user_identity'] = user_identity + + # Ensure parent directory exists + self.token_file.parent.mkdir(parents=True, exist_ok=True) + + # Write token to file + with open(self.token_file, 'w') as f: + json.dump(token_data, f, indent=2) + + # Set restrictive permissions (owner read/write only) + # On Windows, this may not have the same effect but we try anyway + try: + os.chmod(self.token_file, stat.S_IRUSR | stat.S_IWUSR) + except (OSError, AttributeError): + # Permissions may not work on all platforms + pass + + return True + except Exception as e: + print(f'Failed to save token: {e}') + return False + + def load_token(self) -> dict | None: + """ + Load OAuth token from file. + + Returns: + Token data dict or None if file doesn't exist or is invalid + """ + try: + if not self.token_file.exists(): + return None + + with open(self.token_file) as f: + return json.load(f) + except Exception: + return None + + def get_valid_token(self) -> str | None: + """ + Get a valid access token, checking expiration. + + Returns: + Access token string if valid, None if expired or not found + """ + token_data = self.load_token() + + if not token_data: + return None + + # Check if token has expired + if 'expires_at' in token_data: + expires_at = datetime.fromisoformat(token_data['expires_at']) + # Add 5 minute buffer before expiration + if datetime.now() >= (expires_at - timedelta(minutes=5)): + return None + + return token_data.get('access_token') + + def is_expired(self) -> bool: + """ + Check if the stored token is expired. + + Returns: + True if expired or no token, False if still valid + """ + return self.get_valid_token() is None + + def clear_token(self) -> bool: + """ + Delete the stored token file. + + Returns: + True if successful, False otherwise + """ + try: + if self.token_file.exists(): + self.token_file.unlink() + return True + except Exception: + return False + + def get_token_info(self) -> dict | None: + """ + Get information about the stored token without returning the token itself. + + Returns: + Dict with token metadata or None if no token + """ + token_data = self.load_token() + + if not token_data: + return None + + info = { + 'saved_at': token_data.get('saved_at'), + 'expires_at': token_data.get('expires_at'), + 'token_type': token_data.get('token_type', 'Bearer'), + 'has_refresh_token': 'refresh_token' in token_data, + 'is_valid': self.get_valid_token() is not None, + 'token_file': str(self.token_file), + } + + # Include user info if available + user_identity = token_data.get('user_identity', {}) + if user_identity: + info['username'] = user_identity.get('username', '') + + # Calculate time remaining + if 'expires_at' in token_data: + try: + expires_at = datetime.fromisoformat(token_data['expires_at']) + now = datetime.now() + if now < expires_at: + time_remaining = expires_at - now + info['expires_in_seconds'] = int( + time_remaining.total_seconds() + ) + else: + info['expires_in_seconds'] = 0 + except (ValueError, TypeError): + pass + + return info diff --git a/apps/commcare/oauth/urls.py b/apps/commcare/oauth/urls.py new file mode 100644 index 00000000..2ad7e99a --- /dev/null +++ b/apps/commcare/oauth/urls.py @@ -0,0 +1,13 @@ +""" +URL patterns for CommCare OAuth integration. +""" + +from django.urls import path + +from apps.commcare.oauth import views + +urlpatterns = [ + path('initiate/', views.oauth_initiate, name='oauth_initiate'), + path('callback/', views.oauth_callback, name='oauth_callback'), + path('disconnect/', views.oauth_disconnect, name='oauth_disconnect'), +] diff --git a/apps/commcare/oauth/utils.py b/apps/commcare/oauth/utils.py new file mode 100644 index 00000000..b9ff65d1 --- /dev/null +++ b/apps/commcare/oauth/utils.py @@ -0,0 +1,371 @@ +""" +OAuth utility functions for CommCare HQ integration. + +Provides PKCE generation, token exchange, refresh, and session management. +""" + +import base64 +import hashlib +import secrets +import time +from typing import TYPE_CHECKING, TypedDict + +import httpx +from django.conf import settings + +if TYPE_CHECKING: + from django.http import HttpRequest + + +class OAuthTokenData(TypedDict, total=False): + """Structure of OAuth token data stored in session.""" + + access_token: str + refresh_token: str + token_type: str + expires_at: float # Unix timestamp + scope: str + commcare_email: str + server_url: str + + +# Session key for storing CommCare OAuth data +COMMCARE_OAUTH_SESSION_KEY = 'commcare_oauth' + +# Session key for OAuth state during flow +OAUTH_STATE_SESSION_KEY = 'commcare_oauth_state' +OAUTH_PKCE_SESSION_KEY = 'commcare_oauth_pkce' + + +def generate_pkce_pair() -> tuple[str, str]: + """ + Generate PKCE code verifier and challenge for secure OAuth flow. + + Returns: + Tuple of (code_verifier, code_challenge) + """ + # Generate a cryptographically random code verifier + code_verifier = ( + base64.urlsafe_b64encode(secrets.token_bytes(32)) + .decode('utf-8') + .rstrip('=') + ) + + # Create SHA256 hash and base64url encode it for the challenge + code_challenge = ( + base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode('utf-8')).digest() + ) + .decode('utf-8') + .rstrip('=') + ) + + return code_verifier, code_challenge + + +def generate_state() -> str: + """Generate a cryptographically random state parameter for CSRF protection.""" + return secrets.token_urlsafe(32) + + +def get_commcare_hq_url() -> str: + """Get the CommCare HQ base URL from settings.""" + return getattr(settings, 'COMMCARE_HQ_URL', 'https://www.commcarehq.org') + + +def get_oauth_authorize_url() -> str: + """Get the CommCare HQ OAuth authorization endpoint.""" + return f'{get_commcare_hq_url()}/oauth/authorize/' + + +def get_oauth_token_url() -> str: + """Get the CommCare HQ OAuth token endpoint.""" + return f'{get_commcare_hq_url()}/oauth/token/' + + +def get_identity_url() -> str: + """Get the CommCare HQ identity API endpoint.""" + return f'{get_commcare_hq_url()}/api/v0.5/identity/' + + +def get_user_domains_url() -> str: + """Get the CommCare HQ user domains API endpoint.""" + return f'{get_commcare_hq_url()}/api/v0.5/user_domains/' + + +def exchange_code_for_token( + code: str, + redirect_uri: str, + code_verifier: str, + client_id: str | None = None, + client_secret: str | None = None, +) -> dict | None: + """ + Exchange an authorization code for access and refresh tokens. + + Args: + code: The authorization code from the OAuth callback + redirect_uri: The redirect URI used in the authorization request + code_verifier: The PKCE code verifier + client_id: OAuth client ID (defaults to settings) + client_secret: OAuth client secret (defaults to settings, optional for public clients) + + Returns: + Token response dict or None if exchange fails + """ + if client_id is None: + client_id = getattr(settings, 'COMMCARE_OAUTH_CLIENT_ID', '') + + if client_secret is None: + client_secret = getattr(settings, 'COMMCARE_OAUTH_CLIENT_SECRET', '') + + token_data = { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirect_uri, + 'client_id': client_id, + 'code_verifier': code_verifier, + } + + # Include client secret if available (for confidential clients) + if client_secret: + token_data['client_secret'] = client_secret + + try: + response = httpx.post( + get_oauth_token_url(), + data=token_data, + timeout=30, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError: + return None + except Exception: + return None + + +def refresh_access_token( + refresh_token: str, + client_id: str | None = None, + client_secret: str | None = None, +) -> dict | None: + """ + Refresh an expired access token using a refresh token. + + Args: + refresh_token: The refresh token + client_id: OAuth client ID (defaults to settings) + client_secret: OAuth client secret (defaults to settings) + + Returns: + New token response dict or None if refresh fails + """ + if client_id is None: + client_id = getattr(settings, 'COMMCARE_OAUTH_CLIENT_ID', '') + + if client_secret is None: + client_secret = getattr(settings, 'COMMCARE_OAUTH_CLIENT_SECRET', '') + + token_data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token, + 'client_id': client_id, + } + + if client_secret: + token_data['client_secret'] = client_secret + + try: + response = httpx.post( + get_oauth_token_url(), + data=token_data, + timeout=30, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError: + return None + except Exception: + return None + + +def fetch_user_identity(access_token: str) -> dict | None: + """ + Fetch user identity information from CommCare HQ. + + Args: + access_token: Valid OAuth access token + + Returns: + User identity dict with username, email, etc. or None if request fails + """ + try: + response = httpx.get( + get_identity_url(), + headers={'Authorization': f'Bearer {access_token}'}, + timeout=30, + ) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError: + return None + except Exception: + return None + + +def fetch_user_domains(access_token: str) -> list[dict] | None: + """ + Fetch the list of domains the user has access to. + + Args: + access_token: Valid OAuth access token + + Returns: + List of domain dicts with 'domain_name' and 'project_name', or None if request fails + """ + try: + response = httpx.get( + get_user_domains_url(), + headers={'Authorization': f'Bearer {access_token}'}, + timeout=30, + ) + response.raise_for_status() + data = response.json() + return data.get('objects', []) + except httpx.HTTPStatusError: + return None + except Exception: + return None + + +def get_commcare_oauth_session( + request: 'HttpRequest', +) -> OAuthTokenData | None: + """ + Get CommCare OAuth token data from the session. + + Args: + request: Django request object + + Returns: + OAuth token data dict or None if not present + """ + return request.session.get(COMMCARE_OAUTH_SESSION_KEY) + + +def set_commcare_oauth_session( + request: 'HttpRequest', token_data: OAuthTokenData +) -> None: + """ + Store CommCare OAuth token data in the session. + + Args: + request: Django request object + token_data: OAuth token data to store + """ + request.session[COMMCARE_OAUTH_SESSION_KEY] = token_data + + +def clear_commcare_oauth_session(request: 'HttpRequest') -> None: + """ + Clear CommCare OAuth token data from the session. + + Args: + request: Django request object + """ + if COMMCARE_OAUTH_SESSION_KEY in request.session: + del request.session[COMMCARE_OAUTH_SESSION_KEY] + + # Also clear any pending OAuth state + if OAUTH_STATE_SESSION_KEY in request.session: + del request.session[OAUTH_STATE_SESSION_KEY] + if OAUTH_PKCE_SESSION_KEY in request.session: + del request.session[OAUTH_PKCE_SESSION_KEY] + + +def has_valid_oauth_token(request: 'HttpRequest') -> bool: + """ + Check if the session has a valid (non-expired) OAuth token. + + Args: + request: Django request object + + Returns: + True if valid token exists, False otherwise + """ + oauth_data = get_commcare_oauth_session(request) + if not oauth_data: + return False + + access_token = oauth_data.get('access_token') + if not access_token: + return False + + # Check expiration with 5-minute buffer + expires_at = oauth_data.get('expires_at', 0) + buffer_seconds = 5 * 60 + if time.time() >= (expires_at - buffer_seconds): + return False + + return True + + +def get_valid_access_token(request: 'HttpRequest') -> str | None: + """ + Get a valid access token from the session, refreshing if needed. + + Args: + request: Django request object + + Returns: + Valid access token or None if unavailable + """ + oauth_data = get_commcare_oauth_session(request) + if not oauth_data: + return None + + access_token = oauth_data.get('access_token') + expires_at = oauth_data.get('expires_at', 0) + refresh_token = oauth_data.get('refresh_token') + + # Check if token is still valid (with 5-minute buffer) + buffer_seconds = 5 * 60 + if time.time() < (expires_at - buffer_seconds): + return access_token + + # Try to refresh the token + if refresh_token: + new_token_data = refresh_access_token(refresh_token) + if new_token_data: + # Update session with new tokens + oauth_data['access_token'] = new_token_data['access_token'] + if 'refresh_token' in new_token_data: + oauth_data['refresh_token'] = new_token_data['refresh_token'] + if 'expires_in' in new_token_data: + oauth_data['expires_at'] = ( + time.time() + new_token_data['expires_in'] + ) + set_commcare_oauth_session(request, oauth_data) + return new_token_data['access_token'] + + return None + + +def validate_email_match(oauth_email: str, user_email: str) -> bool: + """ + Validate that the OAuth email matches the user's account email. + + This is a security measure to ensure users can only connect their own + CommCare account, not impersonate others. + + Args: + oauth_email: Email from CommCare OAuth + user_email: Email of the logged-in user + + Returns: + True if emails match (case-insensitive), False otherwise + """ + if not oauth_email or not user_email: + return False + return oauth_email.lower().strip() == user_email.lower().strip() diff --git a/apps/commcare/oauth/views.py b/apps/commcare/oauth/views.py new file mode 100644 index 00000000..903560e0 --- /dev/null +++ b/apps/commcare/oauth/views.py @@ -0,0 +1,223 @@ +""" +OAuth views for CommCare HQ integration. + +Handles the OAuth authorization flow: initiate, callback, and disconnect. +""" + +import time +from urllib.parse import urlencode + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from apps.commcare.oauth.utils import ( + OAUTH_PKCE_SESSION_KEY, + OAUTH_STATE_SESSION_KEY, + clear_commcare_oauth_session, + exchange_code_for_token, + fetch_user_identity, + generate_pkce_pair, + generate_state, + get_commcare_hq_url, + get_oauth_authorize_url, + set_commcare_oauth_session, + validate_email_match, +) + + +@login_required +def oauth_initiate(request): + """ + Initiate the CommCare OAuth authorization flow. + + Generates PKCE and state parameters, stores them in session, + and redirects to CommCare HQ authorization page. + """ + client_id = getattr(settings, 'COMMCARE_OAUTH_CLIENT_ID', '') + + if not client_id: + messages.error( + request, + _( + 'CommCare OAuth is not configured. Please contact your administrator.' + ), + ) + return redirect('commcare:home') + + # Generate PKCE pair and state + code_verifier, code_challenge = generate_pkce_pair() + state = generate_state() + + # Store in session for verification in callback + request.session[OAUTH_STATE_SESSION_KEY] = state + request.session[OAUTH_PKCE_SESSION_KEY] = code_verifier + + # Build the redirect URI + redirect_uri = request.build_absolute_uri( + reverse('commcare:oauth_callback') + ) + + # Build authorization URL + auth_params = { + 'client_id': client_id, + 'redirect_uri': redirect_uri, + 'response_type': 'code', + 'scope': 'access_apis', + 'state': state, + 'code_challenge': code_challenge, + 'code_challenge_method': 'S256', + } + + auth_url = f'{get_oauth_authorize_url()}?{urlencode(auth_params)}' + + return HttpResponseRedirect(auth_url) + + +@login_required +def oauth_callback(request): + """ + Handle the OAuth callback from CommCare HQ. + + Validates state, exchanges code for tokens, fetches user identity, + validates email match, and stores tokens in session. + """ + # Check for errors from CommCare + error = request.GET.get('error') + if error: + error_description = request.GET.get( + 'error_description', 'Unknown error' + ) + messages.error( + request, + _('CommCare authorization failed: %(error)s') + % {'error': error_description}, + ) + return redirect('commcare:home') + + # Get authorization code + code = request.GET.get('code') + if not code: + messages.error( + request, _('No authorization code received from CommCare.') + ) + return redirect('commcare:home') + + # Validate state parameter (CSRF protection) + state = request.GET.get('state') + expected_state = request.session.get(OAUTH_STATE_SESSION_KEY) + + if not state or state != expected_state: + messages.error( + request, _('Invalid state parameter. Please try again.') + ) + return redirect('commcare:home') + + # Get PKCE code verifier from session + code_verifier = request.session.get(OAUTH_PKCE_SESSION_KEY) + if not code_verifier: + messages.error(request, _('Session expired. Please try again.')) + return redirect('commcare:home') + + # Build redirect URI (must match the one used in authorization) + redirect_uri = request.build_absolute_uri( + reverse('commcare:oauth_callback') + ) + + # Exchange code for tokens + token_response = exchange_code_for_token( + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + ) + + if not token_response: + messages.error( + request, + _( + 'Failed to exchange authorization code for tokens. Please try again.' + ), + ) + return redirect('commcare:home') + + access_token = token_response.get('access_token') + if not access_token: + messages.error( + request, _('No access token received. Please try again.') + ) + return redirect('commcare:home') + + # Fetch user identity to get email + identity = fetch_user_identity(access_token) + if not identity: + messages.error( + request, + _( + 'Failed to fetch user identity from CommCare. Please try again.' + ), + ) + return redirect('commcare:home') + + commcare_email = identity.get('username', '') + + # Validate email match + if not validate_email_match(commcare_email, request.user.email): + messages.error( + request, + _( + 'CommCare account email (%(commcare_email)s) does not match your ' + 'CommCare Sync account email (%(sync_email)s). You can only connect ' + 'a CommCare account with the same email address.' + ) + % { + 'commcare_email': commcare_email, + 'sync_email': request.user.email, + }, + ) + return redirect('commcare:home') + + # Calculate expiration timestamp + expires_in = token_response.get('expires_in', 3600) + expires_at = time.time() + expires_in + + # Store tokens in session + oauth_data = { + 'access_token': access_token, + 'refresh_token': token_response.get('refresh_token', ''), + 'token_type': token_response.get('token_type', 'Bearer'), + 'expires_at': expires_at, + 'scope': token_response.get('scope', 'access_apis'), + 'commcare_email': commcare_email, + 'server_url': get_commcare_hq_url(), + } + set_commcare_oauth_session(request, oauth_data) + + # Clean up temporary session data + if OAUTH_STATE_SESSION_KEY in request.session: + del request.session[OAUTH_STATE_SESSION_KEY] + if OAUTH_PKCE_SESSION_KEY in request.session: + del request.session[OAUTH_PKCE_SESSION_KEY] + + messages.success( + request, + _('Successfully connected to CommCare as %(email)s.') + % {'email': commcare_email}, + ) + + return redirect('commcare:home') + + +@login_required +def oauth_disconnect(request): + """ + Disconnect the CommCare OAuth connection. + + Clears OAuth tokens from the session. + """ + clear_commcare_oauth_session(request) + messages.success(request, _('Disconnected from CommCare.')) + return redirect('commcare:home') diff --git a/apps/commcare/urls.py b/apps/commcare/urls.py index 808caebd..580bad40 100644 --- a/apps/commcare/urls.py +++ b/apps/commcare/urls.py @@ -1,12 +1,22 @@ -from django.urls import path -from . import views +from django.urls import include, path +from . import views app_name = 'commcare' urlpatterns = [ - path(r'', views.home, name='home'), - path(r'projects/create/', views.create_project, name='create_project'), - path(r'projects//edit/', views.edit_project, name='edit_project'), - path(r'accounts/create/', views.create_account, name='create_account'), - path(r'accounts//edit/', views.edit_account, name='edit_account'), + path('', views.home, name='home'), + path('projects/create/', views.create_project, name='create_project'), + path( + 'projects//edit/', + views.edit_project, + name='edit_project', + ), + path('accounts/create/', views.create_account, name='create_account'), + path( + 'accounts//edit/', + views.edit_account, + name='edit_account', + ), + # OAuth integration for configuration assistance + path('oauth/', include('apps.commcare.oauth.urls')), ] diff --git a/apps/commcare/views.py b/apps/commcare/views.py index 52b187ca..3cd8ed1b 100644 --- a/apps/commcare/views.py +++ b/apps/commcare/views.py @@ -1,3 +1,6 @@ +import time + +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import HttpResponseRedirect @@ -11,17 +14,43 @@ EditCommCareAccountForm, ) from .models import CommCareAccount, CommCareProject +from .oauth.utils import get_commcare_oauth_session, has_valid_oauth_token @login_required def home(request): projects = CommCareProject.objects.order_by('domain') accounts = CommCareAccount.objects.order_by('username') - return render(request, 'commcare/commcare_home.html', { - 'active_tab': 'commcare', - 'projects': projects, - 'accounts': accounts, - }) + + # Get OAuth connection status + oauth_configured = bool(getattr(settings, 'COMMCARE_OAUTH_CLIENT_ID', '')) + oauth_connected = has_valid_oauth_token(request) + oauth_email = None + oauth_expires_in_minutes = None + + if oauth_connected: + oauth_data = get_commcare_oauth_session(request) + if oauth_data: + oauth_email = oauth_data.get('commcare_email', '') + expires_at = oauth_data.get('expires_at', 0) + if expires_at: + remaining_seconds = expires_at - time.time() + if remaining_seconds > 0: + oauth_expires_in_minutes = int(remaining_seconds / 60) + + return render( + request, + 'commcare/commcare_home.html', + { + 'active_tab': 'commcare', + 'projects': projects, + 'accounts': accounts, + 'oauth_configured': oauth_configured, + 'oauth_connected': oauth_connected, + 'oauth_email': oauth_email, + 'oauth_expires_in_minutes': oauth_expires_in_minutes, + }, + ) @login_required @@ -30,34 +59,48 @@ def create_project(request): form = CommCareProjectForm(request.POST, request.FILES) if form.is_valid(): project = form.save() - messages.success(request, f'Project {project.domain} was successfully added.') + messages.success( + request, f'Project {project.domain} was successfully added.' + ) return HttpResponseRedirect(reverse('commcare:home')) else: form = CommCareProjectForm() - return render(request, 'commcare/create_project.html', { - 'active_tab': 'create_project', - 'form': form, - }) + return render( + request, + 'commcare/create_project.html', + { + 'active_tab': 'create_project', + 'form': form, + }, + ) @login_required def edit_project(request, project_id): project = get_object_or_404(CommCareProject, id=project_id) if request.method == 'POST': - form = CommCareProjectForm(request.POST, request.FILES, instance=project) + form = CommCareProjectForm( + request.POST, request.FILES, instance=project + ) if form.is_valid(): project = form.save() - messages.success(request, f'Project {project} was successfully saved.') + messages.success( + request, f'Project {project} was successfully saved.' + ) return HttpResponseRedirect(reverse('commcare:home')) else: form = CommCareProjectForm(instance=project) - return render(request, 'commcare/edit_project.html', { - 'active_tab': 'commcare', - 'form': form, - 'project': project, - }) + return render( + request, + 'commcare/edit_project.html', + { + 'active_tab': 'commcare', + 'form': form, + 'project': project, + }, + ) @login_required @@ -68,34 +111,53 @@ def create_account(request): account = form.save(commit=False) account.owner = request.user account.save() - messages.success(request, f'Account {account.username} was successfully added.') + messages.success( + request, f'Account {account.username} was successfully added.' + ) return HttpResponseRedirect(reverse('commcare:home')) else: form = CreateCommCareAccountForm() - return render(request, 'commcare/create_account.html', { - 'active_tab': 'create_account', - 'form': form, - }) + return render( + request, + 'commcare/create_account.html', + { + 'active_tab': 'create_account', + 'form': form, + }, + ) @login_required def edit_account(request, account_id): account = get_object_or_404(CommCareAccount, id=account_id) if not request.user == account.owner: - messages.warning(request, _("Sorry, you don't have permission to edit that account")) + messages.warning( + request, _("Sorry, you don't have permission to edit that account") + ) return HttpResponseRedirect(reverse('commcare:home')) if request.method == 'POST': - form = EditCommCareAccountForm(request.POST, request.FILES, instance=account) + form = EditCommCareAccountForm( + request.POST, request.FILES, instance=account + ) if form.is_valid(): account = form.save() - messages.success(request, _('Account {account} was successfully saved.').format(account=account)) + messages.success( + request, + _('Account {account} was successfully saved.').format( + account=account + ), + ) return HttpResponseRedirect(reverse('commcare:home')) else: form = EditCommCareAccountForm(instance=account) - return render(request, 'commcare/edit_account.html', { - 'active_tab': 'commcare', - 'form': form, - 'account': account, - }) + return render( + request, + 'commcare/edit_account.html', + { + 'active_tab': 'commcare', + 'form': form, + 'account': account, + }, + ) diff --git a/commcare_sync/settings.py b/commcare_sync/settings.py index 61e3633f..b5d43739 100644 --- a/commcare_sync/settings.py +++ b/commcare_sync/settings.py @@ -4,6 +4,11 @@ import json import os +from dotenv import load_dotenv + +# Load .env file if it exists +load_dotenv() + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -222,6 +227,20 @@ COMMCARE_SYNC_UI_PAGE_SIZE = 25 +# CommCare OAuth Configuration +# Used for interactive configuration assistance (not production exports) +# See .env.dev for setup instructions + +COMMCARE_HQ_URL = os.environ.get('COMMCARE_HQ_URL', 'https://www.commcarehq.org') + +# Web OAuth (confidential client) - for browser-based OAuth flow +COMMCARE_OAUTH_CLIENT_ID = os.environ.get('COMMCARE_OAUTH_CLIENT_ID', '') +COMMCARE_OAUTH_CLIENT_SECRET = os.environ.get('COMMCARE_OAUTH_CLIENT_SECRET', '') + +# CLI OAuth (public client) - for command-line token acquisition (no secret, uses PKCE) +COMMCARE_OAUTH_CLI_CLIENT_ID = os.environ.get('COMMCARE_OAUTH_CLI_CLIENT_ID', '') + + PROJECT_METADATA = { 'NAME': 'CommCare Sync', 'URL': 'http://localhost:8001', diff --git a/docs/config.md b/docs/config.md index 3e170eb4..82d81ecf 100644 --- a/docs/config.md +++ b/docs/config.md @@ -56,6 +56,15 @@ Data Pipeline (steps 7 & 8). Ask a site admin to create an account for you and share credentials, then change your password. +### Optional: Set up CommCare OAuth + +CommCare OAuth integration enables easier configuration by allowing you to +browse your available domains, case types, and export configurations directly +from CommCare. See [OAuth Setup](oauth_setup.md) for instructions. + +**Note:** OAuth is for configuration assistance only. You still need API keys +for running production exports. + ### Export your data To export data, follow the following steps: diff --git a/docs/oauth_setup.md b/docs/oauth_setup.md new file mode 100644 index 00000000..31c8baa1 --- /dev/null +++ b/docs/oauth_setup.md @@ -0,0 +1,155 @@ +CommCare OAuth Setup +==================== + +CommCare Sync supports OAuth integration with CommCare HQ to enable easier +configuration. OAuth allows you to: + +- Auto-fetch your available CommCare domains/projects +- Browse available case types and form types +- Fetch DET export configurations directly from CommCare +- Validate export configurations against live CommCare data + +**Note:** OAuth is for interactive configuration assistance only. Production +data exports still require API keys configured in your CommCare Account. + +## Prerequisites + +- A CommCare HQ account with access to at least one domain +- Admin access to register OAuth applications (or ask your CommCare admin) + +## Step 1: Register OAuth Applications in CommCare HQ + +You need to create **two OAuth applications** in CommCare HQ: + +1. **Web Application** (Confidential Client) - for browser-based OAuth +2. **CLI Application** (Public Client) - for command-line tools + +### Create the Web Application + +1. Log into CommCare HQ at https://www.commcarehq.org +2. Navigate to **OAuth Applications**: https://www.commcarehq.org/oauth/applications/ +3. Click **New Application** +4. Fill in the following: + - **Name**: `CommCare Sync Web` + - **Client Type**: `Confidential` + - **Authorization Grant Type**: `Authorization code` + - **Redirect URIs**: + ``` + http://localhost:8001/commcare/oauth/callback/ + https://your-production-domain.com/commcare/oauth/callback/ + ``` +5. Click **Save** +6. Note the **Client ID** and **Client Secret** + +### Create the CLI Application + +1. Click **New Application** again +2. Fill in the following: + - **Name**: `CommCare Sync CLI` + - **Client Type**: `Public` + - **Authorization Grant Type**: `Authorization code` + - **Redirect URIs**: + ``` + http://localhost:8765/callback + ``` +3. Click **Save** +4. Note the **Client ID** (no secret for public clients) + +## Step 2: Configure Environment Variables + +1. Copy `.env.dev` to `.env`: + ```bash + cp .env.dev .env + ``` + +2. Edit `.env` and uncomment/fill in the OAuth settings: + ```bash + COMMCARE_OAUTH_CLIENT_ID=your_web_client_id + COMMCARE_OAUTH_CLIENT_SECRET=your_web_client_secret + COMMCARE_OAUTH_CLI_CLIENT_ID=your_cli_client_id + ``` + +## Step 3: Test the Connection + +### Test CLI OAuth + +```bash +# Acquire a token via browser +python manage.py get_commcare_token + +# Test the connection +python manage.py test_oauth_connection +``` + +The `get_commcare_token` command will: +1. Open your browser to CommCare HQ authorization page +2. Wait for you to authorize +3. Save the token to `~/.commcare-sync/commcare_token.json` + +### Test Web OAuth + +1. Start the development server: `python manage.py runserver` +2. Navigate to http://localhost:8001/commcare/ +3. Click **Connect with CommCare** +4. Authorize in CommCare HQ +5. Verify the status shows "Connected" with your email + +## Security Notes + +### Email Matching + +For security, the CommCare OAuth email **must match** your CommCare Sync +account email. This prevents users from connecting CommCare accounts that +don't belong to them. + +### Token Storage + +- **Web OAuth**: Tokens are stored in the Django session (not the database) + and expire when the session expires or when CommCare revokes the token. +- **CLI OAuth**: Tokens are stored in `~/.commcare-sync/commcare_token.json` + with restricted file permissions. + +### API Keys vs OAuth + +| Feature | OAuth | API Key | +|---------|-------|---------| +| Configuration UI | Yes | No | +| Domain browsing | Yes | No | +| Export config fetching | Yes | Yes | +| Production exports | No | Yes | +| Background jobs | No | Yes | + +OAuth is for interactive configuration; API keys are for production exports. + +## Troubleshooting + +### "OAuth is not configured" + +Ensure `COMMCARE_OAUTH_CLIENT_ID` is set in your environment or settings. + +### "Email mismatch" error + +Your CommCare account email must match your CommCare Sync account email. +Log into CommCare with the same email address you use for CommCare Sync. + +### "Failed to exchange authorization code" + +Check that: +- The redirect URI in CommCare HQ matches exactly (including trailing slash) +- The client secret is correct (for web app) +- CommCare HQ is accessible + +### Token expired + +OAuth tokens expire after a period (typically 1 hour). Simply reconnect +by clicking "Connect with CommCare" again, or run `get_commcare_token` for CLI. + +## Management Commands + +| Command | Description | +|---------|-------------| +| `get_commcare_token` | Acquire OAuth token via browser | +| `test_oauth_connection` | Test token validity and CommCare API | +| `test_fetch_domains` | Test fetching available domains | +| `test_fetch_case_types` | Test fetching case types for a domain | +| `test_fetch_det_exports` | Test fetching DET export configurations | diff --git a/pyproject.toml b/pyproject.toml index 0510a49b..cb2eb062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ "commcare-export", "pyopenssl>=20.0.1", # here to force upgrades of commcare-export dependencies "db-query-fwd", + "httpx", # for OAuth HTTP requests + "python-dotenv", # for loading .env files ] [project.optional-dependencies] diff --git a/templates/commcare/commcare_home.html b/templates/commcare/commcare_home.html index c047e346..c9be3482 100644 --- a/templates/commcare/commcare_home.html +++ b/templates/commcare/commcare_home.html @@ -5,6 +5,61 @@

{% trans "CommCare Home" %}

{% trans "View and manage your CommCare connections" %}

+ +{# OAuth Connection Status #} +
+

{% trans "CommCare OAuth Connection" %}

+

{% trans "Connect with CommCare to enable easier configuration" %}

+ + {% if oauth_connected %} +
+
+
+

+ + {% trans "Connected" %} + {% trans "as" %} {{ oauth_email }} +

+ {% if oauth_expires_in_minutes %} +

+ {% blocktrans with minutes=oauth_expires_in_minutes %}Token expires in {{ minutes }} minutes{% endblocktrans %} +

+ {% endif %} +
+ +
+
+

+ {% trans "Your OAuth connection enables features like auto-fetching available domains and export configurations. For running exports, you still need to configure an API key in your CommCare Account below." %} +

+ {% else %} +
+

+ + {% trans "Connect with CommCare to enable easier configuration, such as auto-fetching your available domains and export configurations." %} +

+
+ {% if oauth_configured %} + + + {% trans "Connect with CommCare" %} + + {% else %} +
+

+ + {% trans "OAuth is not configured. Contact your administrator to set up CommCare OAuth integration." %} +

+
+ {% endif %} + {% endif %} +
+

{% trans "Projects" %}

{% trans "Manage the CommCare project spaces you'll connect to here" %}

diff --git a/uv.lock b/uv.lock index 0f73b7f7..6d7e0837 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, ] +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + [[package]] name = "asgiref" version = "3.11.0" @@ -317,8 +330,10 @@ dependencies = [ { name = "django-anymail" }, { name = "django-celery-beat" }, { name = "django-reversion" }, + { name = "httpx" }, { name = "psycopg2-binary" }, { name = "pyopenssl" }, + { name = "python-dotenv" }, ] [package.optional-dependencies] @@ -362,8 +377,10 @@ requires-dist = [ { name = "django-celery-beat" }, { name = "django-reversion", specifier = ">=5.0.4" }, { name = "gunicorn", extras = ["eventlet"], marker = "extra == 'prod'" }, + { name = "httpx" }, { name = "psycopg2-binary" }, { name = "pyopenssl", specifier = ">=20.0.1" }, + { name = "python-dotenv" }, ] provides-extras = ["prod"] @@ -674,6 +691,43 @@ eventlet = [ { name = "eventlet" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1155,6 +1209,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "pytz" version = "2025.2"