From 43d657594dabbcd44ddbccd29c267c126db0efba Mon Sep 17 00:00:00 2001 From: Jonathan Jackson Date: Sun, 1 Feb 2026 14:48:16 -0700 Subject: [PATCH 1/2] Add AI skills documentation and git fork workflow - Update AGENTS.md to reference .claude/skills/ for project skills - Add git-fork-workflow skill documenting PR contribution process Co-authored-by: Cursor --- .claude/skills/git-fork-workflow/SKILL.md | 30 +++++++++++++++++++++++ AGENTS.md | 4 +++ 2 files changed, 34 insertions(+) create mode 100644 .claude/skills/git-fork-workflow/SKILL.md 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/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. From f2317282089c2af273206e5dc1b1efe0c703042c Mon Sep 17 00:00:00 2001 From: Jonathan Jackson Date: Mon, 2 Feb 2026 09:55:19 -0700 Subject: [PATCH 2/2] Add CommCare OAuth integration for configuration assistance This adds OAuth support for connecting to CommCare HQ to make configuration easier. OAuth enables browsing available domains and (in future PRs) fetching export configurations, case types, and form types directly from CommCare. Key features: - Web OAuth flow with session-based token storage - CLI OAuth flow with local file token storage (~/.commcare-sync/) - Management commands: get_commcare_token, test_oauth_connection - UI integration showing connection status on CommCare home page - Support for both confidential (web) and public (CLI) OAuth clients - Auto-loading of .env files via python-dotenv OAuth is for configuration assistance only - production exports still require API keys for reliability and security. Note: This is Phase 1 of the OAuth integration. Future PRs will add: - API client for fetching domains, case types, exports - Form enhancements with OAuth-fetched data - Optional CommCare login as an additional auth method Co-authored-by: Cursor --- .env.dev | 15 + .gitignore | 3 + apps/commcare/management/__init__.py | 0 apps/commcare/management/commands/__init__.py | 0 .../management/commands/get_commcare_token.py | 141 +++++++ .../commands/test_oauth_connection.py | 160 ++++++++ apps/commcare/oauth/__init__.py | 22 ++ apps/commcare/oauth/cli/__init__.py | 14 + apps/commcare/oauth/cli/client.py | 294 ++++++++++++++ apps/commcare/oauth/cli/token_manager.py | 187 +++++++++ apps/commcare/oauth/urls.py | 13 + apps/commcare/oauth/utils.py | 371 ++++++++++++++++++ apps/commcare/oauth/views.py | 223 +++++++++++ apps/commcare/urls.py | 24 +- apps/commcare/views.py | 122 ++++-- commcare_sync/settings.py | 19 + docs/config.md | 9 + docs/oauth_setup.md | 155 ++++++++ pyproject.toml | 2 + templates/commcare/commcare_home.html | 55 +++ uv.lock | 63 +++ 21 files changed, 1855 insertions(+), 37 deletions(-) create mode 100644 apps/commcare/management/__init__.py create mode 100644 apps/commcare/management/commands/__init__.py create mode 100644 apps/commcare/management/commands/get_commcare_token.py create mode 100644 apps/commcare/management/commands/test_oauth_connection.py create mode 100644 apps/commcare/oauth/__init__.py create mode 100644 apps/commcare/oauth/cli/__init__.py create mode 100644 apps/commcare/oauth/cli/client.py create mode 100644 apps/commcare/oauth/cli/token_manager.py create mode 100644 apps/commcare/oauth/urls.py create mode 100644 apps/commcare/oauth/utils.py create mode 100644 apps/commcare/oauth/views.py create mode 100644 docs/oauth_setup.md 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/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"