Merge pull request #772 from christianlouis/copilot/fix-dropbox-authentication-error
Fix Dropbox OAuth "Invalid redirect_uri" by adding PUBLIC_BASE_URL config
This commit is contained in:
@@ -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)
|
||||
|
||||
+15
-2
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+25
-1
@@ -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).
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<h3 class="text-xl font-medium mb-4">Step 3: Set OAuth 2 Redirect URI</h3>
|
||||
<ol class="list-decimal ml-6 space-y-3">
|
||||
<li>In your app's settings page, go to the "OAuth 2" section</li>
|
||||
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ request.url.scheme }}://{{ request.url.netloc }}/dropbox-callback</code></li>
|
||||
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ callback_url }}</code></li>
|
||||
<li>Click "Add" to save the redirect URI</li>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -298,6 +298,9 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||
const globalCredsAvailable = {{ 'true' if global_creds_available else 'false' }};
|
||||
// Redirect URI for OAuth: prefer server-provided value (respects PUBLIC_BASE_URL),
|
||||
// fall back to window.location.origin for resilience.
|
||||
const dropboxCallbackUrl = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
|
||||
|
||||
// Store integration_id if provided (for per-user OAuth flow)
|
||||
const integrationId = "{{ integration_id or '' }}";
|
||||
@@ -390,7 +393,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
startAuthFlowBtn.addEventListener('click', function() {
|
||||
const appKey = document.getElementById('app-key').value.trim();
|
||||
const appSecret = appSecretInput.value.trim();
|
||||
const redirectUri = window.location.origin + "/dropbox-callback";
|
||||
const redirectUri = dropboxCallbackUrl;
|
||||
|
||||
if (!appKey) {
|
||||
showModal('error', 'Validation Error', 'Please enter your App Key');
|
||||
|
||||
@@ -108,7 +108,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUri = window.location.origin + "/dropbox-callback";
|
||||
// Use server-provided callback URL (respects PUBLIC_BASE_URL) with fallback to window.location.origin
|
||||
const redirectUri = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
|
||||
|
||||
// Automatically exchange the code for a refresh token
|
||||
if (code) {
|
||||
|
||||
@@ -416,3 +416,110 @@ class TestSaveDropboxSettings:
|
||||
# .env write is best-effort; endpoint should still succeed via DB write
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildDropboxRedirectUri:
|
||||
"""Tests for the _build_dropbox_redirect_uri helper."""
|
||||
|
||||
def test_uses_public_base_url_when_set(self):
|
||||
"""When PUBLIC_BASE_URL is configured, redirect URI should use it."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
with patch("app.api.dropbox.settings") as mock_settings:
|
||||
mock_settings.public_base_url = "https://myapp.example.com"
|
||||
from app.api.dropbox import _build_dropbox_redirect_uri
|
||||
|
||||
mock_request = MagicMock()
|
||||
result = _build_dropbox_redirect_uri(mock_request)
|
||||
|
||||
assert result == "https://myapp.example.com/dropbox-callback"
|
||||
|
||||
def test_uses_public_base_url_strips_trailing_slash(self):
|
||||
"""PUBLIC_BASE_URL with trailing slash should be handled correctly."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
with patch("app.api.dropbox.settings") as mock_settings:
|
||||
mock_settings.public_base_url = "https://myapp.example.com/"
|
||||
from app.api.dropbox import _build_dropbox_redirect_uri
|
||||
|
||||
mock_request = MagicMock()
|
||||
result = _build_dropbox_redirect_uri(mock_request)
|
||||
|
||||
assert result == "https://myapp.example.com/dropbox-callback"
|
||||
|
||||
def test_falls_back_to_request_when_public_base_url_not_set(self):
|
||||
"""When PUBLIC_BASE_URL is not set, use request scheme and netloc."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
with patch("app.api.dropbox.settings") as mock_settings:
|
||||
mock_settings.public_base_url = None
|
||||
from app.api.dropbox import _build_dropbox_redirect_uri
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.url.scheme = "https"
|
||||
mock_request.url.netloc = "other.example.com"
|
||||
result = _build_dropbox_redirect_uri(mock_request)
|
||||
|
||||
assert result == "https://other.example.com/dropbox-callback"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGlobalAuthorizeUrl:
|
||||
"""Tests for GET /api/dropbox/global-authorize-url endpoint."""
|
||||
|
||||
@patch("app.api.dropbox.settings")
|
||||
def test_returns_authorize_url(self, mock_settings, client):
|
||||
"""Test that a valid authorize URL is returned when global creds are configured."""
|
||||
mock_settings.dropbox_allow_global_credentials_for_integrations = True
|
||||
mock_settings.dropbox_app_key = "test-app-key"
|
||||
mock_settings.dropbox_app_secret = "test-app-secret"
|
||||
mock_settings.public_base_url = "https://example.com"
|
||||
|
||||
response = client.get("/api/dropbox/global-authorize-url")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "authorize_url" in data
|
||||
assert "https://www.dropbox.com/oauth2/authorize" in data["authorize_url"]
|
||||
assert "client_id=test-app-key" in data["authorize_url"]
|
||||
# redirect_uri should be URL-encoded
|
||||
assert "redirect_uri=" in data["authorize_url"]
|
||||
assert "https%3A%2F%2Fexample.com%2Fdropbox-callback" in data["authorize_url"]
|
||||
|
||||
@patch("app.api.dropbox.settings")
|
||||
def test_returns_403_when_global_creds_disabled(self, mock_settings, client):
|
||||
"""Test 403 when global credentials for integrations are disabled."""
|
||||
mock_settings.dropbox_allow_global_credentials_for_integrations = False
|
||||
mock_settings.dropbox_app_key = "test-app-key"
|
||||
mock_settings.dropbox_app_secret = "test-app-secret"
|
||||
|
||||
response = client.get("/api/dropbox/global-authorize-url")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@patch("app.api.dropbox.settings")
|
||||
def test_returns_503_when_creds_not_configured(self, mock_settings, client):
|
||||
"""Test 503 when global Dropbox credentials are not configured."""
|
||||
mock_settings.dropbox_allow_global_credentials_for_integrations = True
|
||||
mock_settings.dropbox_app_key = None
|
||||
mock_settings.dropbox_app_secret = None
|
||||
|
||||
response = client.get("/api/dropbox/global-authorize-url")
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
@patch("app.api.dropbox.settings")
|
||||
def test_redirect_uri_uses_public_base_url(self, mock_settings, client):
|
||||
"""Redirect URI in authorize URL must use PUBLIC_BASE_URL when configured."""
|
||||
mock_settings.dropbox_allow_global_credentials_for_integrations = True
|
||||
mock_settings.dropbox_app_key = "my-key"
|
||||
mock_settings.dropbox_app_secret = "my-secret"
|
||||
mock_settings.public_base_url = "https://prod.example.com"
|
||||
|
||||
response = client.get("/api/dropbox/global-authorize-url")
|
||||
|
||||
assert response.status_code == 200
|
||||
authorize_url = response.json()["authorize_url"]
|
||||
# The redirect_uri must be URL-encoded and contain the public base URL
|
||||
assert "https%3A%2F%2Fprod.example.com%2Fdropbox-callback" in authorize_url
|
||||
|
||||
@@ -144,3 +144,37 @@ class TestDropboxViews:
|
||||
assert response.status_code == 200
|
||||
assert b"/Documents/Uploads" in response.content
|
||||
assert b"Back to Integrations" in response.content
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDropboxCallbackUrl:
|
||||
"""Tests that the callback_url is correctly passed to templates."""
|
||||
|
||||
def test_setup_page_includes_callback_url(self, client):
|
||||
"""Setup page should include the callback_url variable in its response."""
|
||||
response = client.get("/dropbox-setup")
|
||||
assert response.status_code == 200
|
||||
# callback_url is embedded in the JS as the dropboxCallbackUrl constant
|
||||
assert b"dropboxCallbackUrl" in response.content
|
||||
|
||||
def test_callback_page_includes_callback_url(self, client):
|
||||
"""Callback page should embed the server-side callback URL."""
|
||||
response = client.get("/dropbox-callback?code=testcode")
|
||||
assert response.status_code == 200
|
||||
# callback_url is used as the redirectUri
|
||||
assert b"redirectUri" in response.content
|
||||
|
||||
def test_setup_page_uses_public_base_url_when_set(self, client):
|
||||
"""When PUBLIC_BASE_URL is configured, it should appear in the redirect URI hint."""
|
||||
with patch("app.views.dropbox.settings") as mock_settings:
|
||||
mock_settings.public_base_url = "https://configured.example.com"
|
||||
mock_settings.dropbox_app_key = ""
|
||||
mock_settings.dropbox_app_secret = ""
|
||||
mock_settings.dropbox_refresh_token = ""
|
||||
mock_settings.dropbox_folder = ""
|
||||
mock_settings.dropbox_allow_global_credentials_for_integrations = False
|
||||
response = client.get("/dropbox-setup")
|
||||
assert response.status_code == 200
|
||||
# The configured public_base_url hostname must appear in the page (redirect URI display)
|
||||
page_text = response.text
|
||||
assert "configured.example.com/dropbox-callback" in page_text
|
||||
|
||||
Reference in New Issue
Block a user