diff --git a/.env.demo b/.env.demo index 4299371d..6920b998 100644 --- a/.env.demo +++ b/.env.demo @@ -3,6 +3,7 @@ WORKDIR=/workdir DATABASE_URL=sqlite:///./app/database.db REDIS_URL=redis://redis:6379/0 EXTERNAL_HOSTNAME=docuelevate.example.com +# PUBLIC_BASE_URL=https://docuelevate.example.com # Full URL with scheme; required when X-Forwarded-Proto is not forwarded by your proxy GOTENBERG_URL=http://gotenberg:3000 ALLOW_FILE_DELETE=true # Allow deletion of file records COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2) diff --git a/app/api/dropbox.py b/app/api/dropbox.py index 2707e1c0..73cddec4 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -5,6 +5,7 @@ Dropbox API endpoints import logging import os from typing import Annotated, Optional +from urllib.parse import quote import httpx from fastapi import APIRouter, Depends, Form, HTTPException, Request, status @@ -23,6 +24,18 @@ logger = logging.getLogger(__name__) router = APIRouter() +def _build_dropbox_redirect_uri(request: Request) -> str: + """Build the Dropbox OAuth callback redirect URI. + + Uses ``PUBLIC_BASE_URL`` when configured (recommended for deployments behind + a reverse proxy that doesn't forward ``X-Forwarded-Proto``). Falls back to + deriving the URI from the incoming request's scheme and host headers. + """ + if settings.public_base_url: + return settings.public_base_url.rstrip("/") + "/dropbox-callback" + return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback" + + @router.get("/dropbox/global-authorize-url") @require_login async def dropbox_global_authorize_url(request: Request): @@ -43,13 +56,13 @@ async def dropbox_global_authorize_url(request: Request): status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Global Dropbox credentials are not configured", ) - redirect_uri = str(request.base_url).rstrip("/") + "/dropbox-callback" + redirect_uri = _build_dropbox_redirect_uri(request) authorize_url = ( "https://www.dropbox.com/oauth2/authorize" f"?client_id={settings.dropbox_app_key}" "&response_type=code" "&token_access_type=offline" - f"&redirect_uri={redirect_uri}" + f"&redirect_uri={quote(redirect_uri, safe='')}" ) return {"authorize_url": authorize_url} diff --git a/app/config.py b/app/config.py index 64701642..70ba5c36 100644 --- a/app/config.py +++ b/app/config.py @@ -193,6 +193,16 @@ class Settings(BaseSettings): google_docai_processor_id: Optional[str] = None google_docai_location: str = "us" # Processor location, e.g. "us" or "eu" external_hostname: str = "localhost" # Default to localhost + public_base_url: Optional[str] = Field( + default=None, + description=( + "The full public base URL of the application, including scheme " + "(e.g., 'https://docuelevate.example.com'). " + "When set, this overrides the auto-detected URL for OAuth redirect URIs. " + "This is required when the application is behind a reverse proxy that does " + "not forward X-Forwarded-Proto headers correctly." + ), + ) # --------------------------------------------------------------------------- # Document Translation Settings diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 58be76d9..644a62eb 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -99,6 +99,18 @@ SETTING_METADATA = { "required": True, # Required for OAuth redirects and external URLs "restart_required": True, }, + "public_base_url": { + "category": "Core", + "description": ( + "Full public base URL including scheme (e.g., https://docuelevate.example.com). " + "When set, overrides auto-detected URLs for OAuth redirect URIs. " + "Required when behind a reverse proxy that does not forward X-Forwarded-Proto." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, "debug": { "category": "Core", "description": "Enable debug mode for verbose logging", diff --git a/app/views/dropbox.py b/app/views/dropbox.py index db3c3b30..5723650a 100644 --- a/app/views/dropbox.py +++ b/app/views/dropbox.py @@ -14,6 +14,19 @@ from app.views.base import APIRouter, Depends, get_db, require_login, settings, router = APIRouter() +def _get_dropbox_callback_url(request: Request) -> str: + """Return the Dropbox OAuth callback URL. + + Uses ``PUBLIC_BASE_URL`` when configured so that the redirect URI displayed + to the user (and registered in the Dropbox developer console) matches the + one used in the OAuth authorization request. Falls back to deriving the URL + from the incoming request when ``PUBLIC_BASE_URL`` is not set. + """ + if settings.public_base_url: + return settings.public_base_url.rstrip("/") + "/dropbox-callback" + return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback" + + @router.get("/dropbox-setup") @require_login async def dropbox_setup_page( @@ -30,6 +43,8 @@ async def dropbox_setup_page( path from the integration's existing config is pre-populated; global admin credentials are never exposed in this mode. """ + callback_url = _get_dropbox_callback_url(request) + if integration_id is not None: owner_id = get_current_owner_id(request) integration = ( @@ -67,6 +82,7 @@ async def dropbox_setup_page( "app_secret_value": "", "refresh_token_value": "", "global_creds_available": global_creds_available, + "callback_url": callback_url, }, ) @@ -86,6 +102,7 @@ async def dropbox_setup_page( "integration_id": integration_id, "integration_name": None, "integration_type": None, + "callback_url": callback_url, }, ) @@ -116,5 +133,6 @@ async def dropbox_callback(request: Request, code: str = None, error: str = None "app_key_value": "", # The callback will prioritize sessionStorage values "app_secret_value": "", # The callback will prioritize sessionStorage values "folder_path": "", # The callback will prioritize sessionStorage values + "callback_url": _get_dropbox_callback_url(request), }, ) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index fefa9fce..a84ce393 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -19,6 +19,7 @@ Configuration is primarily done through environment variables specified in a `.e | `WORKDIR` | Working directory for the application. | `/workdir` | | `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` | | `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` | +| `PUBLIC_BASE_URL` | Full public base URL including scheme (e.g., `https://docuelevate.example.com`). When set, overrides auto-detected URLs used for OAuth redirect URIs. **Required when your reverse proxy does not forward `X-Forwarded-Proto` headers.** | *(not set)* | | `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` | | `COMPLIANCE_ENABLED` | Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). | `true` | | `FACTORY_RESET_ON_STARTUP` | Wipe all user data on every startup (demo/testing). | `false` | diff --git a/docs/DropboxSetup.md b/docs/DropboxSetup.md index 8923a595..b0e44852 100644 --- a/docs/DropboxSetup.md +++ b/docs/DropboxSetup.md @@ -128,7 +128,31 @@ If you encounter issues with Dropbox integration: 1. **Authentication Errors**: Make sure your App Key and App Secret are correct 2. **Token Expired**: Click "Refresh Token" button on the setup page to obtain a new token 3. **Folder Permissions**: Ensure your app has the correct permissions enabled for file operations -4. **Invalid Redirect URI**: Verify that the redirect URI in your app settings matches the one used in the authentication flow +4. **Invalid Redirect URI**: See section below for the most common cause and fix. 5. **Rate Limiting**: Dropbox API has rate limits; if exceeded, wait and try again +### Fixing "Invalid redirect_uri" Error + +This error appears on the Dropbox authorization page when the redirect URI in the OAuth request does not match any URI registered in your Dropbox app console. + +**Most common cause**: The application is deployed behind a reverse proxy (Traefik, Nginx, Caddy) that does **not** forward the `X-Forwarded-Proto: https` header to DocuElevate. Without this header, the server cannot determine that it is being accessed over HTTPS and may construct an `http://` redirect URI, while the registered URI in Dropbox is `https://`. + +**Fix**: + +Option 1 – Configure your proxy to forward `X-Forwarded-Proto`: + +```nginx +proxy_set_header X-Forwarded-Proto $scheme; +``` + +Option 2 – Set `PUBLIC_BASE_URL` in your environment (recommended for most deployments): + +```bash +PUBLIC_BASE_URL=https://docuelevate.example.com +``` + +When `PUBLIC_BASE_URL` is set, DocuElevate uses it directly for all OAuth redirect URIs instead of trying to infer the scheme from request headers. This is the most reliable option. + +After setting `PUBLIC_BASE_URL`, ensure the Dropbox app console redirect URI matches exactly (e.g., `https://docuelevate.example.com/dropbox-callback`). The setup wizard at `/dropbox-setup` will show you the exact URI to register. + For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md). diff --git a/frontend/templates/dropbox.html b/frontend/templates/dropbox.html index 46d4cc6d..6c41bd96 100644 --- a/frontend/templates/dropbox.html +++ b/frontend/templates/dropbox.html @@ -104,7 +104,7 @@

Step 3: Set OAuth 2 Redirect URI

  1. In your app's settings page, go to the "OAuth 2" section
  2. -
  3. Add a redirect URI: {{ request.url.scheme }}://{{ request.url.netloc }}/dropbox-callback
  4. +
  5. Add a redirect URI: {{ callback_url }}
  6. Click "Add" to save the redirect URI
@@ -298,6 +298,9 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}