feat(test): add Phase 5b integration and E2E tests
Add comprehensive integration and end-to-end test suites: - Integration tests for API flows (authorization, token, verification) - Integration tests for middleware chain and security headers - Integration tests for domain verification services - E2E tests for complete authentication flows - E2E tests for error scenarios and edge cases - Shared test fixtures and utilities in conftest.py - Rename Dockerfile to Containerfile for Podman compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
"""
|
||||
Pytest configuration and shared fixtures.
|
||||
|
||||
This module provides comprehensive test fixtures for Phase 5b integration
|
||||
and E2E testing. Fixtures are organized by category for maintainability.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENVIRONMENT SETUP FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
@@ -38,3 +51,675 @@ def reset_config_before_test(monkeypatch):
|
||||
monkeypatch.setenv("GONDULF_BASE_URL", "http://localhost:8000")
|
||||
monkeypatch.setenv("GONDULF_DEBUG", "true")
|
||||
monkeypatch.setenv("GONDULF_DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db_path(tmp_path) -> Path:
|
||||
"""Create a temporary database path."""
|
||||
return tmp_path / "test.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_database(test_db_path):
|
||||
"""
|
||||
Create and initialize a test database.
|
||||
|
||||
Yields:
|
||||
Database: Initialized database instance with tables created
|
||||
"""
|
||||
from gondulf.database.connection import Database
|
||||
|
||||
db = Database(f"sqlite:///{test_db_path}")
|
||||
db.ensure_database_directory()
|
||||
db.run_migrations()
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configured_test_app(monkeypatch, test_db_path):
|
||||
"""
|
||||
Create a fully configured FastAPI test app with temporary database.
|
||||
|
||||
This fixture handles all environment configuration and creates
|
||||
a fresh app instance for each test.
|
||||
"""
|
||||
# Set required environment variables
|
||||
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
|
||||
monkeypatch.setenv("GONDULF_BASE_URL", "https://auth.example.com")
|
||||
monkeypatch.setenv("GONDULF_DATABASE_URL", f"sqlite:///{test_db_path}")
|
||||
monkeypatch.setenv("GONDULF_DEBUG", "true")
|
||||
|
||||
# Import after environment is configured
|
||||
from gondulf.main import app
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(configured_test_app) -> Generator[TestClient, None, None]:
|
||||
"""
|
||||
Create a TestClient with properly configured app.
|
||||
|
||||
Yields:
|
||||
TestClient: FastAPI test client with startup events run
|
||||
"""
|
||||
with TestClient(configured_test_app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CODE STORAGE FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_code_storage():
|
||||
"""
|
||||
Create a test code storage instance.
|
||||
|
||||
Returns:
|
||||
CodeStore: Fresh code storage for testing
|
||||
"""
|
||||
from gondulf.storage import CodeStore
|
||||
return CodeStore(ttl_seconds=600)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_auth_code(test_code_storage) -> tuple[str, dict]:
|
||||
"""
|
||||
Create a valid authorization code with metadata.
|
||||
|
||||
Args:
|
||||
test_code_storage: Code storage fixture
|
||||
|
||||
Returns:
|
||||
Tuple of (code, metadata)
|
||||
"""
|
||||
code = "test_auth_code_12345"
|
||||
metadata = {
|
||||
"client_id": "https://client.example.com",
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
"state": "xyz123",
|
||||
"me": "https://user.example.com",
|
||||
"scope": "",
|
||||
"code_challenge": "abc123def456",
|
||||
"code_challenge_method": "S256",
|
||||
"created_at": 1234567890,
|
||||
"expires_at": 1234568490,
|
||||
"used": False
|
||||
}
|
||||
test_code_storage.store(f"authz:{code}", metadata)
|
||||
return code, metadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_auth_code(test_code_storage) -> tuple[str, dict]:
|
||||
"""
|
||||
Create an expired authorization code.
|
||||
|
||||
Returns:
|
||||
Tuple of (code, metadata) where the code is expired
|
||||
"""
|
||||
import time
|
||||
code = "expired_auth_code_12345"
|
||||
metadata = {
|
||||
"client_id": "https://client.example.com",
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
"state": "xyz123",
|
||||
"me": "https://user.example.com",
|
||||
"scope": "",
|
||||
"code_challenge": "abc123def456",
|
||||
"code_challenge_method": "S256",
|
||||
"created_at": 1000000000,
|
||||
"expires_at": 1000000001, # Expired long ago
|
||||
"used": False
|
||||
}
|
||||
# Store with 0 TTL to make it immediately expired
|
||||
test_code_storage.store(f"authz:{code}", metadata, ttl=0)
|
||||
return code, metadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def used_auth_code(test_code_storage) -> tuple[str, dict]:
|
||||
"""
|
||||
Create an already-used authorization code.
|
||||
|
||||
Returns:
|
||||
Tuple of (code, metadata) where the code is marked as used
|
||||
"""
|
||||
code = "used_auth_code_12345"
|
||||
metadata = {
|
||||
"client_id": "https://client.example.com",
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
"state": "xyz123",
|
||||
"me": "https://user.example.com",
|
||||
"scope": "",
|
||||
"code_challenge": "abc123def456",
|
||||
"code_challenge_method": "S256",
|
||||
"created_at": 1234567890,
|
||||
"expires_at": 1234568490,
|
||||
"used": True # Already used
|
||||
}
|
||||
test_code_storage.store(f"authz:{code}", metadata)
|
||||
return code, metadata
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SERVICE FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_token_service(test_database):
|
||||
"""
|
||||
Create a test token service with database.
|
||||
|
||||
Args:
|
||||
test_database: Database fixture
|
||||
|
||||
Returns:
|
||||
TokenService: Token service configured for testing
|
||||
"""
|
||||
from gondulf.services.token_service import TokenService
|
||||
return TokenService(
|
||||
database=test_database,
|
||||
token_length=32,
|
||||
token_ttl=3600
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dns_service():
|
||||
"""
|
||||
Create a mock DNS service.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked DNSService for testing
|
||||
"""
|
||||
mock = Mock()
|
||||
mock.verify_txt_record = Mock(return_value=True)
|
||||
mock.resolve_txt = Mock(return_value=["gondulf-verify-domain"])
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dns_service_failure():
|
||||
"""
|
||||
Create a mock DNS service that returns failures.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked DNSService that simulates DNS failures
|
||||
"""
|
||||
mock = Mock()
|
||||
mock.verify_txt_record = Mock(return_value=False)
|
||||
mock.resolve_txt = Mock(return_value=[])
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_email_service():
|
||||
"""
|
||||
Create a mock email service.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked EmailService for testing
|
||||
"""
|
||||
mock = Mock()
|
||||
mock.send_verification_code = Mock(return_value=None)
|
||||
mock.messages_sent = []
|
||||
|
||||
def track_send(email, code, domain):
|
||||
mock.messages_sent.append({
|
||||
"email": email,
|
||||
"code": code,
|
||||
"domain": domain
|
||||
})
|
||||
|
||||
mock.send_verification_code.side_effect = track_send
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_html_fetcher():
|
||||
"""
|
||||
Create a mock HTML fetcher service.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked HTMLFetcherService
|
||||
"""
|
||||
mock = Mock()
|
||||
mock.fetch = Mock(return_value="<html><body></body></html>")
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_html_fetcher_with_email():
|
||||
"""
|
||||
Create a mock HTML fetcher that returns a page with rel=me email.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked HTMLFetcherService with email in page
|
||||
"""
|
||||
mock = Mock()
|
||||
html = '''
|
||||
<html>
|
||||
<body>
|
||||
<a href="mailto:test@example.com" rel="me">Email</a>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
mock.fetch = Mock(return_value=html)
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_happ_parser():
|
||||
"""
|
||||
Create a mock h-app parser.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked HAppParser
|
||||
"""
|
||||
from gondulf.services.happ_parser import ClientMetadata
|
||||
|
||||
mock = Mock()
|
||||
mock.fetch_and_parse = Mock(return_value=ClientMetadata(
|
||||
name="Test Application",
|
||||
url="https://app.example.com",
|
||||
logo="https://app.example.com/logo.png"
|
||||
))
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rate_limiter():
|
||||
"""
|
||||
Create a mock rate limiter that always allows requests.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked RateLimiter
|
||||
"""
|
||||
mock = Mock()
|
||||
mock.check_rate_limit = Mock(return_value=True)
|
||||
mock.record_attempt = Mock()
|
||||
mock.reset = Mock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rate_limiter_exceeded():
|
||||
"""
|
||||
Create a mock rate limiter that blocks all requests.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked RateLimiter that simulates rate limit exceeded
|
||||
"""
|
||||
mock = Mock()
|
||||
mock.check_rate_limit = Mock(return_value=False)
|
||||
mock.record_attempt = Mock()
|
||||
return mock
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DOMAIN VERIFICATION FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verification_service(mock_dns_service, mock_email_service, mock_html_fetcher_with_email, test_code_storage):
|
||||
"""
|
||||
Create a domain verification service with all mocked dependencies.
|
||||
|
||||
Args:
|
||||
mock_dns_service: Mock DNS service
|
||||
mock_email_service: Mock email service
|
||||
mock_html_fetcher_with_email: Mock HTML fetcher with email
|
||||
test_code_storage: Code storage fixture
|
||||
|
||||
Returns:
|
||||
DomainVerificationService: Service configured with mocks
|
||||
"""
|
||||
from gondulf.services.domain_verification import DomainVerificationService
|
||||
from gondulf.services.relme_parser import RelMeParser
|
||||
|
||||
return DomainVerificationService(
|
||||
dns_service=mock_dns_service,
|
||||
email_service=mock_email_service,
|
||||
code_storage=test_code_storage,
|
||||
html_fetcher=mock_html_fetcher_with_email,
|
||||
relme_parser=RelMeParser()
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verification_service_dns_failure(mock_dns_service_failure, mock_email_service, mock_html_fetcher_with_email, test_code_storage):
|
||||
"""
|
||||
Create a verification service where DNS verification fails.
|
||||
|
||||
Returns:
|
||||
DomainVerificationService: Service with failing DNS
|
||||
"""
|
||||
from gondulf.services.domain_verification import DomainVerificationService
|
||||
from gondulf.services.relme_parser import RelMeParser
|
||||
|
||||
return DomainVerificationService(
|
||||
dns_service=mock_dns_service_failure,
|
||||
email_service=mock_email_service,
|
||||
code_storage=test_code_storage,
|
||||
html_fetcher=mock_html_fetcher_with_email,
|
||||
relme_parser=RelMeParser()
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLIENT CONFIGURATION FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_client() -> dict[str, str]:
|
||||
"""
|
||||
Basic IndieAuth client configuration.
|
||||
|
||||
Returns:
|
||||
Dict with client_id and redirect_uri
|
||||
"""
|
||||
return {
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_metadata() -> dict[str, str]:
|
||||
"""
|
||||
Client configuration that would have h-app metadata.
|
||||
|
||||
Returns:
|
||||
Dict with client configuration
|
||||
"""
|
||||
return {
|
||||
"client_id": "https://rich-app.example.com",
|
||||
"redirect_uri": "https://rich-app.example.com/auth/callback",
|
||||
"expected_name": "Rich Application",
|
||||
"expected_logo": "https://rich-app.example.com/logo.png"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def malicious_client() -> dict[str, Any]:
|
||||
"""
|
||||
Client with potentially malicious configuration for security testing.
|
||||
|
||||
Returns:
|
||||
Dict with malicious inputs
|
||||
"""
|
||||
return {
|
||||
"client_id": "https://evil.example.com",
|
||||
"redirect_uri": "https://evil.example.com/steal",
|
||||
"state": "<script>alert('xss')</script>",
|
||||
"me": "javascript:alert('xss')"
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUTHORIZATION REQUEST FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_auth_request() -> dict[str, str]:
|
||||
"""
|
||||
Complete valid authorization request parameters.
|
||||
|
||||
Returns:
|
||||
Dict with all required authorization parameters
|
||||
"""
|
||||
return {
|
||||
"response_type": "code",
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"state": "random_state_12345",
|
||||
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
"code_challenge_method": "S256",
|
||||
"me": "https://user.example.com",
|
||||
"scope": ""
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_request_missing_client_id(valid_auth_request) -> dict[str, str]:
|
||||
"""Authorization request missing client_id."""
|
||||
request = valid_auth_request.copy()
|
||||
del request["client_id"]
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_request_missing_redirect_uri(valid_auth_request) -> dict[str, str]:
|
||||
"""Authorization request missing redirect_uri."""
|
||||
request = valid_auth_request.copy()
|
||||
del request["redirect_uri"]
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_request_invalid_response_type(valid_auth_request) -> dict[str, str]:
|
||||
"""Authorization request with invalid response_type."""
|
||||
request = valid_auth_request.copy()
|
||||
request["response_type"] = "token" # Invalid - we only support "code"
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_request_missing_pkce(valid_auth_request) -> dict[str, str]:
|
||||
"""Authorization request missing PKCE code_challenge."""
|
||||
request = valid_auth_request.copy()
|
||||
del request["code_challenge"]
|
||||
return request
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TOKEN FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_token(test_token_service) -> tuple[str, dict]:
|
||||
"""
|
||||
Generate a valid access token.
|
||||
|
||||
Args:
|
||||
test_token_service: Token service fixture
|
||||
|
||||
Returns:
|
||||
Tuple of (token, metadata)
|
||||
"""
|
||||
token = test_token_service.generate_token(
|
||||
me="https://user.example.com",
|
||||
client_id="https://app.example.com",
|
||||
scope=""
|
||||
)
|
||||
metadata = test_token_service.validate_token(token)
|
||||
return token, metadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expired_token_metadata() -> dict[str, Any]:
|
||||
"""
|
||||
Metadata representing an expired token (for manual database insertion).
|
||||
|
||||
Returns:
|
||||
Dict with expired token metadata
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
|
||||
token = "expired_test_token_12345"
|
||||
return {
|
||||
"token": token,
|
||||
"token_hash": hashlib.sha256(token.encode()).hexdigest(),
|
||||
"me": "https://user.example.com",
|
||||
"client_id": "https://app.example.com",
|
||||
"scope": "",
|
||||
"issued_at": datetime.utcnow() - timedelta(hours=2),
|
||||
"expires_at": datetime.utcnow() - timedelta(hours=1), # Already expired
|
||||
"revoked": False
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP MOCKING FIXTURES (for urllib)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urlopen():
|
||||
"""
|
||||
Mock urllib.request.urlopen for HTTP request testing.
|
||||
|
||||
Yields:
|
||||
MagicMock: Mock that can be configured per test
|
||||
"""
|
||||
with patch('gondulf.services.html_fetcher.urllib.request.urlopen') as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urlopen_success(mock_urlopen):
|
||||
"""
|
||||
Configure mock_urlopen to return a successful response.
|
||||
|
||||
Args:
|
||||
mock_urlopen: Base mock fixture
|
||||
|
||||
Returns:
|
||||
MagicMock: Configured mock
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = b"<html><body>Test</body></html>"
|
||||
mock_response.status = 200
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
return mock_urlopen
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urlopen_with_happ(mock_urlopen):
|
||||
"""
|
||||
Configure mock_urlopen to return a page with h-app metadata.
|
||||
|
||||
Args:
|
||||
mock_urlopen: Base mock fixture
|
||||
|
||||
Returns:
|
||||
MagicMock: Configured mock
|
||||
"""
|
||||
html = b'''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test App</title></head>
|
||||
<body>
|
||||
<div class="h-app">
|
||||
<h1 class="p-name">Example Application</h1>
|
||||
<img class="u-logo" src="https://app.example.com/logo.png" alt="Logo">
|
||||
<a class="u-url" href="https://app.example.com">Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = html
|
||||
mock_response.status = 200
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
return mock_urlopen
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urlopen_timeout(mock_urlopen):
|
||||
"""
|
||||
Configure mock_urlopen to simulate a timeout.
|
||||
|
||||
Args:
|
||||
mock_urlopen: Base mock fixture
|
||||
|
||||
Returns:
|
||||
MagicMock: Configured mock that raises timeout
|
||||
"""
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError("Connection timed out")
|
||||
return mock_urlopen
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def create_app_with_overrides(monkeypatch, tmp_path, **overrides):
|
||||
"""
|
||||
Helper to create a test app with custom dependency overrides.
|
||||
|
||||
Args:
|
||||
monkeypatch: pytest monkeypatch fixture
|
||||
tmp_path: temporary path for database
|
||||
**overrides: Dependency override functions
|
||||
|
||||
Returns:
|
||||
tuple: (app, client, overrides_applied)
|
||||
"""
|
||||
db_path = tmp_path / "test.db"
|
||||
|
||||
monkeypatch.setenv("GONDULF_SECRET_KEY", "a" * 32)
|
||||
monkeypatch.setenv("GONDULF_BASE_URL", "https://auth.example.com")
|
||||
monkeypatch.setenv("GONDULF_DATABASE_URL", f"sqlite:///{db_path}")
|
||||
monkeypatch.setenv("GONDULF_DEBUG", "true")
|
||||
|
||||
from gondulf.main import app
|
||||
|
||||
for dependency, override in overrides.items():
|
||||
app.dependency_overrides[dependency] = override
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def extract_code_from_redirect(location: str) -> str:
|
||||
"""
|
||||
Extract authorization code from redirect URL.
|
||||
|
||||
Args:
|
||||
location: Redirect URL with code parameter
|
||||
|
||||
Returns:
|
||||
str: Authorization code
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
parsed = urlparse(location)
|
||||
params = parse_qs(parsed.query)
|
||||
return params.get("code", [None])[0]
|
||||
|
||||
|
||||
def extract_error_from_redirect(location: str) -> dict[str, str]:
|
||||
"""
|
||||
Extract error parameters from redirect URL.
|
||||
|
||||
Args:
|
||||
location: Redirect URL with error parameters
|
||||
|
||||
Returns:
|
||||
Dict with error and error_description
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
parsed = urlparse(location)
|
||||
params = parse_qs(parsed.query)
|
||||
return {
|
||||
"error": params.get("error", [None])[0],
|
||||
"error_description": params.get("error_description", [None])[0]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user