Implements both IndieAuth flows per W3C specification: - Authentication flow (response_type=id): Code redeemed at authorization endpoint, returns only user identity - Authorization flow (response_type=code): Code redeemed at token endpoint, returns access token Changes: - Authorization endpoint GET: Accept response_type=id (default) and code - Authorization endpoint POST: Handle code verification for authentication flow - Token endpoint: Validate response_type=code for authorization flow - Store response_type in authorization code metadata - Update metadata endpoint: response_types_supported=[code, id], code_challenge_methods_supported=[S256] The default behavior now correctly defaults to response_type=id when omitted, per IndieAuth spec section 5.2. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
729 lines
20 KiB
Python
729 lines
20 KiB
Python
"""
|
|
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)
|
|
def setup_test_config():
|
|
"""
|
|
Setup test configuration before any tests run.
|
|
|
|
This ensures required environment variables are set for test execution.
|
|
"""
|
|
# Set required configuration
|
|
os.environ.setdefault("GONDULF_SECRET_KEY", "test-secret-key-for-testing-only-32chars")
|
|
os.environ.setdefault("GONDULF_BASE_URL", "http://localhost:8000")
|
|
os.environ.setdefault("GONDULF_DEBUG", "true")
|
|
os.environ.setdefault("GONDULF_DATABASE_URL", "sqlite:///:memory:")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_config_before_test(monkeypatch):
|
|
"""
|
|
Reset configuration before each test.
|
|
|
|
This prevents config from one test affecting another test.
|
|
"""
|
|
# Clear all GONDULF_ environment variables
|
|
gondulf_vars = [key for key in os.environ.keys() if key.startswith("GONDULF_")]
|
|
for var in gondulf_vars:
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
# Re-set required test configuration
|
|
monkeypatch.setenv("GONDULF_SECRET_KEY", "test-secret-key-for-testing-only-32chars")
|
|
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 (authorization flow).
|
|
|
|
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",
|
|
"response_type": "code", # Authorization flow - exchange at token endpoint
|
|
"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 (authorization flow).
|
|
|
|
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",
|
|
"response_type": "code", # Authorization flow
|
|
"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 (authorization flow).
|
|
|
|
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",
|
|
"response_type": "code", # Authorization flow
|
|
"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 (for authorization flow).
|
|
|
|
Returns:
|
|
Dict with all required authorization parameters
|
|
"""
|
|
return {
|
|
"response_type": "code", # Authorization flow - exchange at token endpoint
|
|
"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]
|
|
}
|