diff --git a/kinde_fastapi/examples/README.md b/kinde_fastapi/examples/README.md index b585a26d..4cfc6550 100644 --- a/kinde_fastapi/examples/README.md +++ b/kinde_fastapi/examples/README.md @@ -6,7 +6,7 @@ This is an example FastAPI application that demonstrates how to use the Kinde Fa 1. Install the required dependencies: ```bash -pip install fastapi uvicorn jinja2 python-multipart +pip install fastapi uvicorn python-multipart python-dotenv ``` 2. Configure your Kinde application: @@ -14,17 +14,19 @@ pip install fastapi uvicorn jinja2 python-multipart - Set the redirect URI to `http://localhost:8000/callback` - Copy your client ID and client secret -3. Update the configuration in `example_app.py`: - - Replace `your_client_id` with your actual client ID - - Replace `your_client_secret` with your actual client secret - - Update the URLs to match your Kinde domain - - Change the session secret key to a secure value +3. Create a `.env` file in the examples directory with the following variables: +```env +KINDE_CLIENT_ID=your_client_id +KINDE_CLIENT_SECRET=your_client_secret +KINDE_REDIRECT_URI=http://localhost:8000/callback +KINDE_HOST=https://your-domain.kinde.com +``` ## Running the Example -Run the example application: +Run the example application from the SDK root directory: ```bash -python example_app.py +python -m uvicorn kinde_fastapi.examples.example_app:app --reload --port 8000 ``` The application will be available at `http://localhost:8000`. @@ -37,20 +39,30 @@ The application will be available at `http://localhost:8000`. - Session management - Logout -2. **Protected Routes** +2. **Automatic Route Registration** + - The OAuth class automatically registers these routes: + - `/login` - Redirects to Kinde login + - `/callback` - Handles OAuth callback from Kinde + - `/logout` - Logs out the user + - `/register` - Redirects to Kinde registration + - `/user` - Returns user information (JSON) + +3. **Protected Routes** - Example of a protected route that requires authentication - Automatic redirection to login for unauthenticated users -3. **User Information** +4. **User Information** - Retrieving and displaying user information - Session-based user state management ## API Endpoints - `/` - Home page (shows different content based on authentication status) -- `/login` - Redirects to Kinde login -- `/callback` - Handles OAuth callback from Kinde -- `/logout` - Logs out the user +- `/login` - Redirects to Kinde login (auto-registered) +- `/callback` - Handles OAuth callback from Kinde (auto-registered) +- `/logout` - Logs out the user (auto-registered) +- `/register` - Redirects to Kinde registration (auto-registered) +- `/user` - Returns user information as JSON (auto-registered) - `/protected` - Example protected route ## Security Considerations @@ -69,4 +81,4 @@ The application will be available at `http://localhost:8000`. 3. Add more security features 4. Use proper templates instead of inline HTML 5. Add user profile management -6. Implement role-based access control \ No newline at end of file +6. Implement role-based access control diff --git a/kinde_fastapi/examples/example_app.py b/kinde_fastapi/examples/example_app.py index 49bacafe..6431bee7 100644 --- a/kinde_fastapi/examples/example_app.py +++ b/kinde_fastapi/examples/example_app.py @@ -1,304 +1,143 @@ """ -SmartOAuth FastAPI Example Application +Kinde FastAPI Example Application -This example demonstrates how to use the new SmartOAuth client in a FastAPI application. -SmartOAuth automatically detects the execution context (sync vs async) and uses the -appropriate methods, providing a consistent API across different frameworks. +This example demonstrates how to use the Kinde OAuth client in a FastAPI application. +The OAuth class automatically registers authentication routes and handles the OAuth flow. Key Features Demonstrated: -- Automatic context detection (async in FastAPI) -- Both sync and async method usage -- Warning system for suboptimal usage -- Integration with auth modules (sync and async) -- Factory function usage -- Real-world authentication flow +- Simple OAuth setup with FastAPI +- Automatic route registration (/login, /logout, /callback, /register, /user) +- Authentication status checking +- User information retrieval Usage: -1. Set up your environment variables (see .env.example) -2. Run: python -m uvicorn kinde_fastapi.examples.example_app:app --reload -3. Visit http://localhost:5000 +1. Set up your environment variables in a .env file: + - KINDE_CLIENT_ID=your_client_id + - KINDE_CLIENT_SECRET=your_client_secret + - KINDE_REDIRECT_URI=http://localhost:8000/callback + - KINDE_HOST=https://your-domain.kinde.com + +2. Run from the SDK root directory: + python -m uvicorn kinde_fastapi.examples.example_app:app --reload --port 8000 + +3. Visit http://localhost:8000 + +Available Routes (automatically registered): +- /login - Redirects to Kinde login +- /logout - Logs out the user +- /callback - Handles OAuth callback +- /register - Redirects to Kinde registration +- /user - Returns user information (redirects to login if not authenticated) """ -from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse -from starlette.middleware.sessions import SessionMiddleware +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, RedirectResponse from .session import InMemorySessionMiddleware -from pathlib import Path import os +import html from dotenv import load_dotenv import logging -from kinde_sdk import SmartOAuth, create_oauth_client -from kinde_sdk.auth import claims, feature_flags, permissions, tokens, async_claims -from kinde_sdk.management import ManagementClient; -from kinde_sdk.management.management_token_manager import ManagementTokenManager -import requests +from kinde_sdk.auth.oauth import OAuth logger = logging.getLogger(__name__) -# Load environment variables from .env file -load_dotenv() +# Load environment variables from .env file located alongside this script +load_dotenv(os.path.join(os.path.dirname(__file__), ".env")) # Initialize FastAPI app app = FastAPI(title="Kinde FastAPI Example") # Add session middleware with proper configuration +# This is required for storing session data between requests app.add_middleware( InMemorySessionMiddleware, max_age=3600, # 1 hour https_only=False ) -# Initialize Kinde SmartOAuth with FastAPI framework -# SmartOAuth automatically detects the async context and uses the appropriate methods -kinde_oauth = SmartOAuth( +# Initialize Kinde OAuth with FastAPI framework +# This automatically registers /login, /logout, /callback, /register, /user routes +kinde_oauth = OAuth( framework="fastapi", app=app ) -# Alternative: You can also use the factory function -# kinde_oauth = create_oauth_client( -# async_mode=None, # None means auto-detect (SmartOAuth) -# framework="fastapi", -# app=app -# ) - # Example home route @app.get("/", response_class=HTMLResponse) -async def home(request: Request): +async def home(): """ Home page that shows different content based on authentication status. """ if kinde_oauth.is_authenticated(): - # In FastAPI (async context), SmartOAuth will use async methods automatically - # You can use either sync or async methods - SmartOAuth will handle the context - - # Option 1: Use async methods (recommended in async context) - user_async = await kinde_oauth.get_user_info_async() - - # Use the async version for better performance - user = user_async - - # Validate environment variables - domain = os.getenv("KINDE_DOMAIN") - client_id = os.getenv("KINDE_MANAGEMENT_CLIENT_ID") - client_secret = os.getenv("KINDE_MANAGEMENT_CLIENT_SECRET") - - if not all([domain, client_id, client_secret]): - return """ + try: + user = kinde_oauth.get_user_info() + email = html.escape(user.get('email', 'User')) + email_display = html.escape(user.get('email', 'N/A')) + given_name = html.escape(user.get('given_name', '')) + family_name = html.escape(user.get('family_name', '')) + user_id = html.escape(user.get('sub', 'N/A')) + return f""" -

Configuration Error

-

Missing required environment variables for management client.

- Logout +

Welcome, {email}!

+

You are logged in.

+

User Information:

+ +
+

View Full User Info (JSON)

+

Logout

""" - - management_client = ManagementClient( - domain=domain, - client_id=client_id, - client_secret=client_secret - ) - try: - api_response = management_client.get_users() - user_count = len(api_response.users) if hasattr(api_response, 'users') else 0 except Exception as e: - logger.error(f"Failed to fetch users: {e}") - user_count = 0 - - # Demonstrate both sync and async auth module usage - claims_sync = await claims.get_all_claims() - claims_async = await async_claims.get_all_claims() - feature_flags_data = await feature_flags.get_all_flags() - permissions_data = await permissions.get_permissions() - access_token = tokens.get_token_manager().get_access_token() - - return f""" - - -

Welcome, {user.get('email')}!

-

SmartOAuth Demo - FastAPI (Async Context)

-

User Info (async): {user.get('email')}

-

Claims (sync): {claims_sync}

-

Claims (async): {claims_async}

-

Feature Flags: {feature_flags_data}

-

Permissions: {permissions_data}

-

Access Token: {access_token[:20]}...

-

Management Users: {user_count} user(s) found

-

Note: SmartOAuth automatically detected the async context and used async methods.

-

You are logged in.

-
-

Demo Routes:

- -
- Logout - - - """ + error_msg = html.escape(str(e)) + logger.exception("Error getting user info") + return f""" + + +

Error

+

Failed to get user information: {error_msg}

+ Logout + + + """ + return """ -

Welcome to the SmartOAuth Example App

+

Welcome to the Kinde FastAPI Example

You are not logged in.

-

This example demonstrates SmartOAuth in a FastAPI application.

- Login with SmartOAuth +

This example demonstrates Kinde OAuth integration with FastAPI.

+

Login | Register

- + """ -@app.get("/login") -async def login(): - """ - Initiate login with SmartOAuth. - """ - try: - # SmartOAuth will automatically use async methods in FastAPI context - login_url = await kinde_oauth.login() - return {"login_url": login_url} - except Exception as e: - logger.error(f"Login error: {e}") - return {"error": str(e)} -@app.get("/logout") -async def logout(): +@app.get("/protected") +async def protected_route(): """ - Logout using SmartOAuth. + Example of a protected route that requires authentication. + Redirects to login if not authenticated. """ - try: - # SmartOAuth will automatically use async methods in FastAPI context - logout_url = await kinde_oauth.logout() - return {"logout_url": logout_url} - except Exception as e: - logger.error(f"Logout error: {e}") - return {"error": str(e)} - -@app.get("/call_management_users") -async def call_management_users(): if not kinde_oauth.is_authenticated(): - return {"error": "Not authenticated"} - - domain = os.getenv("KINDE_DOMAIN") - client_id = os.getenv("KINDE_MANAGEMENT_CLIENT_ID") - client_secret = os.getenv("KINDE_MANAGEMENT_CLIENT_SECRET") - - if not all([domain, client_id, client_secret]): - return {"error": "Missing management credentials"} + return RedirectResponse("/login") try: - token_manager = ManagementTokenManager( - domain=domain, - client_id=client_id, - client_secret=client_secret - ) - access_token = token_manager.get_access_token() - - headers = { - "Authorization": f"Bearer {access_token}" + user = kinde_oauth.get_user_info() + return { + "message": "This is a protected route", + "user": user.get('email') } - response = requests.get("http://localhost:8000/management/users", headers=headers) - response.raise_for_status() - return response.json() - except Exception as e: - logger.error(f"Failed to call management users: {e}") - return {"error": str(e)} + except Exception: + logger.exception("Error getting user info in protected route") + return RedirectResponse("/login") -@app.get("/demo_smart_oauth") -async def demo_smart_oauth(): - """ - Demonstrate SmartOAuth features in FastAPI context. - """ - if not kinde_oauth.is_authenticated(): - return {"error": "Not authenticated"} - - # Demonstrate different SmartOAuth usage patterns - results = {} - - # 1. Async methods (recommended in async context) - try: - user_async = await kinde_oauth.get_user_info_async() - results["user_async"] = user_async.get('email') - except Exception as e: - results["user_async_error"] = str(e) - - # 2. Sync methods (will show warning but still work) - try: - user_sync = kinde_oauth.get_user_info() - results["user_sync"] = user_sync.get('email') - except Exception as e: - results["user_sync_error"] = str(e) - - # 3. Auth URL generation (async) - try: - auth_url = await kinde_oauth.generate_auth_url() - results["auth_url"] = auth_url - except Exception as e: - results["auth_url_error"] = str(e) - - # 4. Token retrieval - try: - tokens_data = kinde_oauth.get_tokens(kinde_oauth._framework.get_user_id()) - results["tokens"] = { - "has_access_token": bool(tokens_data.get('access_token')), - "has_refresh_token": bool(tokens_data.get('refresh_token')) - } - except Exception as e: - results["tokens_error"] = str(e) - - # 5. Context detection - results["context_info"] = { - "is_async_context": kinde_oauth._is_async_context(), - "framework": "fastapi", - "authenticated": kinde_oauth.is_authenticated() - } - - return { - "message": "SmartOAuth Demo Results", - "results": results, - "note": "SmartOAuth automatically detected the async context and used appropriate methods." - } - -@app.get("/demo_auth_modules") -async def demo_auth_modules(): - """ - Demonstrate both sync and async auth modules. - """ - if not kinde_oauth.is_authenticated(): - return {"error": "Not authenticated"} - - results = {} - - # Sync auth modules - try: - results["claims_sync"] = await claims.get_all_claims() - except Exception as e: - results["claims_sync_error"] = str(e) - - # Async auth modules - try: - results["claims_async"] = await async_claims.get_all_claims() - except Exception as e: - results["claims_async_error"] = str(e) - - try: - results["feature_flags"] = await feature_flags.get_all_flags() - except Exception as e: - results["feature_flags_error"] = str(e) - - try: - results["permissions"] = await permissions.get_permissions() - except Exception as e: - results["permissions_error"] = str(e) - - return { - "message": "Auth Modules Demo", - "results": results, - "note": "Both sync and async auth modules work in FastAPI context." - } if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="127.0.0.1", port=5000) \ No newline at end of file + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/kinde_flask/examples/example_app.py b/kinde_flask/examples/example_app.py index 689e14af..3b13d399 100644 --- a/kinde_flask/examples/example_app.py +++ b/kinde_flask/examples/example_app.py @@ -4,8 +4,8 @@ from kinde_sdk.auth.oauth import OAuth -# Load environment variables from .env file -load_dotenv() +# Load environment variables from .env file located alongside this script +load_dotenv(os.path.join(os.path.dirname(__file__), ".env")) # Initialize Flask app app = Flask(__name__) diff --git a/kinde_flask/framework/flask_framework.py b/kinde_flask/framework/flask_framework.py index a0ce4cdb..2d69a15a 100644 --- a/kinde_flask/framework/flask_framework.py +++ b/kinde_flask/framework/flask_framework.py @@ -1,16 +1,21 @@ from typing import Optional, TYPE_CHECKING from flask import Flask, request, redirect, session +from flask_session import Session from kinde_sdk.core.framework.framework_interface import FrameworkInterface from kinde_sdk.auth.oauth import OAuth from ..middleware.framework_middleware import FrameworkMiddleware import os import uuid import asyncio -import nest_asyncio +import logging +import secrets +import tempfile if TYPE_CHECKING: from flask import Request +logger = logging.getLogger(__name__) + class FlaskFramework(FrameworkInterface): """ Flask framework implementation. @@ -29,13 +34,35 @@ def __init__(self, app: Optional[Flask] = None): self._initialized = False self._oauth = None - # Configure Flask session - self.app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key') - self.app.config['SESSION_TYPE'] = 'filesystem' + # Configure Flask session for server-side storage + # This is required because OAuth tokens can exceed cookie size limits (~4KB) + secret_key = os.getenv('SECRET_KEY') + if not secret_key: + secret_key = secrets.token_urlsafe(32) + logger.warning( + "SECRET_KEY not set. Generated a random key for this session. " + "Set SECRET_KEY environment variable for production use." + ) + self.app.config['SECRET_KEY'] = secret_key + self.app.config['SESSION_TYPE'] = os.getenv('SESSION_TYPE', 'filesystem') self.app.config['SESSION_PERMANENT'] = False - # Enable nested event loops - nest_asyncio.apply() + session_file_dir = os.getenv('SESSION_FILE_DIR') + if not session_file_dir: + # Create a secure temporary directory with restrictive permissions (0700) + session_file_dir = tempfile.mkdtemp(prefix='kinde_flask_sessions_') + os.chmod(session_file_dir, 0o700) + logger.warning( + f"SESSION_FILE_DIR not set. Using temporary directory: {session_file_dir}. " + "Set SESSION_FILE_DIR environment variable for production use." + ) + self.app.config['SESSION_FILE_DIR'] = session_file_dir + + # Initialize Flask-Session extension + # Without this, SESSION_TYPE is ignored and Flask uses client-side cookie sessions + Session(self.app) + logger.debug("Flask-Session initialized with server-side storage") + def get_name(self) -> str: """ @@ -118,6 +145,27 @@ def set_oauth(self, oauth: OAuth) -> None: """ self._oauth = oauth + @staticmethod + def _run_async(coro): + """ + Run an async coroutine in a new event loop, then close it. + + This helper ensures consistent event loop handling across all routes. + Clears the event loop reference before closing to prevent use-after-close issues. + + Args: + coro: The coroutine to run + + Returns: + The result of the coroutine + """ + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + asyncio.set_event_loop(None) + loop.close() + def _register_kinde_routes(self) -> None: """ Register all Kinde-specific routes with the Flask application. @@ -126,8 +174,6 @@ def _register_kinde_routes(self) -> None: @self.app.route('/login') def login(): """Redirect to Kinde login page.""" - loop = asyncio.get_event_loop() - # Build login options from query parameters login_options = {} @@ -136,7 +182,7 @@ def login(): if invitation_code: login_options['invitation_code'] = invitation_code - login_url = loop.run_until_complete(self._oauth.login(login_options)) + login_url = self._run_async(self._oauth.login(login_options)) return redirect(login_url) # Callback route @@ -204,10 +250,7 @@ def callback(): # Handle async call to handle_redirect try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(self._oauth.handle_redirect(code, user_id, state)) - loop.close() + self._run_async(self._oauth.handle_redirect(code, user_id, state)) except Exception as e: return f"Authentication failed: {str(e)}", 400 @@ -224,16 +267,14 @@ def logout(): """Logout the user and redirect to Kinde logout page.""" user_id = session.get('user_id') session.clear() - loop = asyncio.get_event_loop() - logout_url = loop.run_until_complete(self._oauth.logout(user_id)) + logout_url = self._run_async(self._oauth.logout(user_id)) return redirect(logout_url) # Register route @self.app.route('/register') def register(): """Redirect to Kinde registration page.""" - loop = asyncio.get_event_loop() - register_url = loop.run_until_complete(self._oauth.register()) + register_url = self._run_async(self._oauth.register()) return redirect(register_url) # User info route @@ -242,13 +283,8 @@ def get_user(): """Get the current user's information.""" try: if not self._oauth.is_authenticated(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - login_url = loop.run_until_complete(self._oauth.login()) - return redirect(login_url) - finally: - loop.close() + login_url = self._run_async(self._oauth.login()) + return redirect(login_url) return self._oauth.get_user_info() except Exception as e: diff --git a/kinde_flask/pyproject.toml b/kinde_flask/pyproject.toml index b6b47b1c..6968972b 100644 --- a/kinde_flask/pyproject.toml +++ b/kinde_flask/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "kinde-python-sdk >=1.2.6", "flask >=3.1.2, <3.2.0", "python-dotenv >=1.2.1, <1.3.0", + "flask-session>=0.5.0" ] [project.urls] diff --git a/kinde_sdk/auth/oauth.py b/kinde_sdk/auth/oauth.py index 174a438b..0c2d19d1 100644 --- a/kinde_sdk/auth/oauth.py +++ b/kinde_sdk/auth/oauth.py @@ -332,12 +332,12 @@ async def generate_auth_url( # Generate state if not provided state = login_options.get(LoginOptions.STATE, generate_random_string(32)) search_params["state"] = state - self._session_manager.storage_manager.setItems("state", {"value": state}) + self._session_manager.storage_manager.setItems("user:state", {"value": state}) # Generate nonce if not provided nonce = login_options.get(LoginOptions.NONCE, generate_random_string(16)) search_params["nonce"] = nonce - self._session_manager.storage_manager.setItems("nonce", {"value": nonce}) + self._session_manager.storage_manager.setItems("user:nonce", {"value": nonce}) # Handle PKCE code_verifier = "" @@ -348,7 +348,7 @@ async def generate_auth_url( pkce_data = await generate_pkce_pair(52) # Use 52 chars to match JS implementation code_verifier = pkce_data["code_verifier"] search_params["code_challenge"] = pkce_data["code_challenge"] - self._session_manager.storage_manager.setItems("code_verifier", {"value": code_verifier}) + self._session_manager.storage_manager.setItems("user:code_verifier", {"value": code_verifier}) # Set code challenge method code_challenge_method = login_options.get(LoginOptions.CODE_CHALLENGE_METHOD, "S256") @@ -517,7 +517,7 @@ async def handle_redirect(self, code: str, user_id: str, state: Optional[str] = """ # Verify state if provided if state: - stored_state = self._session_manager.storage_manager.get("state") + stored_state = self._session_manager.storage_manager.get("user:state") self._logger.warning(f"stored_state: {stored_state}, state: {state}") if not stored_state or state != stored_state.get("value"): self._logger.error(f"State mismatch: received {state}, stored {stored_state}") @@ -525,12 +525,12 @@ async def handle_redirect(self, code: str, user_id: str, state: Optional[str] = # Get code verifier for PKCE code_verifier = None - stored_code_verifier = self._session_manager.storage_manager.get("code_verifier") + stored_code_verifier = self._session_manager.storage_manager.get("user:code_verifier") if stored_code_verifier: code_verifier = stored_code_verifier.get("value") # Clean up the used code verifier - self._session_manager.storage_manager.delete("code_verifier") + self._session_manager.storage_manager.delete("user:code_verifier") # Exchange code for tokens try: @@ -567,10 +567,10 @@ async def handle_redirect(self, code: str, user_id: str, state: Optional[str] = # Clean up state if state: - self._session_manager.storage_manager.delete("state") + self._session_manager.storage_manager.delete("user:state") # Clean up nonce - self._session_manager.storage_manager.delete("nonce") + self._session_manager.storage_manager.delete("user:nonce") return { "tokens": token_data, diff --git a/testv2/testv2_core/test_null_framework.py b/testv2/testv2_core/test_null_framework.py index 38808c76..6bf04621 100644 --- a/testv2/testv2_core/test_null_framework.py +++ b/testv2/testv2_core/test_null_framework.py @@ -335,7 +335,7 @@ def test_oauth_login_with_null_framework(self, mock_pkce, mock_random): self.assertIn("http%3A%2F%2Flocalhost%3A8080%2Fcallback", login_url) # Verify state was stored - stored_state = oauth._session_manager.storage_manager.get("state") + stored_state = oauth._session_manager.storage_manager.get("user:state") self.assertIsNotNone(stored_state) self.assertEqual(stored_state["value"], "mocked-state-123") diff --git a/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py index 0b311244..b214aa17 100644 --- a/testv2/testv2_framework/test_flask_framework.py +++ b/testv2/testv2_framework/test_flask_framework.py @@ -1,66 +1,446 @@ import unittest -from unittest.mock import AsyncMock, MagicMock, patch +import os +import tempfile +import shutil +from unittest.mock import Mock, patch, call from flask import Flask - from kinde_flask.framework.flask_framework import FlaskFramework +from kinde_sdk.auth.oauth import OAuth -class TestFlaskFramework(unittest.TestCase): - """Tests for the Flask framework implementation.""" - +class BaseFlaskFrameworkTest(unittest.TestCase): + """Base test class with common setUp/tearDown logic for all Flask framework tests.""" + def setUp(self): - self.app = Flask(__name__) - self.app.config["SECRET_KEY"] = "test-secret" - self.app.config["TESTING"] = True - - self.framework = FlaskFramework(app=self.app) - - # Mock OAuth so no real auth happens - self.mock_oauth = MagicMock() - self.mock_oauth.login = AsyncMock(return_value="https://kinde.example.com/authorize") - self.framework._oauth = self.mock_oauth + """Set up test fixtures. Subclasses can override to customize behavior.""" + # Store original env vars - snapshot ALL env vars + self.original_env = dict(os.environ) + + # Track created frameworks for cleanup + self.created_frameworks = [] + + def tearDown(self): + """Clean up after tests.""" + # Clean up any framework-created session directories + for framework in self.created_frameworks: + if hasattr(framework, 'app') and framework.app: + session_dir = framework.app.config.get('SESSION_FILE_DIR') + if session_dir and os.path.exists(session_dir): + shutil.rmtree(session_dir, ignore_errors=True) + + # Clean up test session directory if it exists + if hasattr(self, 'test_session_dir') and os.path.exists(self.test_session_dir): + shutil.rmtree(self.test_session_dir, ignore_errors=True) + + # Clean up session directory from setUp if it exists + if hasattr(self, '_setup_session_dir'): + session_dir = os.environ.get('SESSION_FILE_DIR') + if session_dir and os.path.exists(session_dir): + shutil.rmtree(session_dir, ignore_errors=True) + + # Remove any env vars added during test and restore original state + current_keys = set(os.environ.keys()) + original_keys = set(self.original_env.keys()) + + # Remove keys that were added during the test + for key in current_keys - original_keys: + del os.environ[key] + + # Restore original values + for key, value in self.original_env.items(): + os.environ[key] = value - # Register routes - self.framework._initialized = False - self.framework._register_kinde_routes() - self.client = self.app.test_client() +class TestFlaskFramework(BaseFlaskFrameworkTest): + """Test cases for FlaskFramework configuration.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + # Clear specific keys for clean test environment + for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: + if key in os.environ: + del os.environ[key] + + # Create a temporary directory for test sessions + self.test_session_dir = tempfile.mkdtemp(prefix='test_flask_sessions_') + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.logger') + def test_secret_key_auto_generation_with_warning(self, mock_logger, _mock_session): + """Test that SECRET_KEY is auto-generated with a warning when not set.""" + # Ensure SECRET_KEY is not set + if 'SECRET_KEY' in os.environ: + del os.environ['SECRET_KEY'] + + framework = FlaskFramework() + self.created_frameworks.append(framework) + + # Verify SECRET_KEY was set + self.assertIsNotNone(framework.app.config.get('SECRET_KEY')) + self.assertGreater(len(framework.app.config['SECRET_KEY']), 0) + + # Verify warning was logged + mock_logger.warning.assert_any_call( + "SECRET_KEY not set. Generated a random key for this session. " + "Set SECRET_KEY environment variable for production use." + ) + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.logger') + def test_secret_key_from_environment(self, mock_logger, _mock_session): + """Test that SECRET_KEY is read from environment variable.""" + test_secret = "my_test_secret_key_12345" + os.environ['SECRET_KEY'] = test_secret + + framework = FlaskFramework() + self.created_frameworks.append(framework) + + # Verify SECRET_KEY was set from env + self.assertEqual(framework.app.config['SECRET_KEY'], test_secret) + + # Verify no warning was logged for SECRET_KEY + warning_calls = [call for call in mock_logger.warning.call_args_list + if 'SECRET_KEY' in str(call)] + self.assertEqual(len(warning_calls), 0) + + @patch('kinde_flask.framework.flask_framework.Session') + def test_session_type_default(self, _mock_session): + """Test that SESSION_TYPE defaults to 'filesystem'.""" + if 'SESSION_TYPE' in os.environ: + del os.environ['SESSION_TYPE'] + + framework = FlaskFramework() + self.created_frameworks.append(framework) + + self.assertEqual(framework.app.config['SESSION_TYPE'], 'filesystem') + + @patch('kinde_flask.framework.flask_framework.Session') + def test_session_type_from_environment(self, _mock_session): + """Test that SESSION_TYPE is read from environment variable.""" + os.environ['SESSION_TYPE'] = 'test-session-type' + + framework = FlaskFramework() + self.created_frameworks.append(framework) + + self.assertEqual(framework.app.config['SESSION_TYPE'], 'test-session-type') + + @patch('kinde_flask.framework.flask_framework.logger') + @patch('kinde_flask.framework.flask_framework.tempfile.mkdtemp') + @patch('kinde_flask.framework.flask_framework.os.chmod') + def test_session_file_dir_auto_generation_with_warning(self, mock_chmod, mock_mkdtemp, + mock_logger): + """Test that SESSION_FILE_DIR is auto-generated with warning when not set.""" + if 'SESSION_FILE_DIR' in os.environ: + del os.environ['SESSION_FILE_DIR'] + + test_temp_dir = '/tmp/test_kinde_sessions_abc123' + mock_mkdtemp.return_value = test_temp_dir + + framework = FlaskFramework() + + # Verify mkdtemp was called with correct prefix + mock_mkdtemp.assert_called_once_with(prefix='kinde_flask_sessions_') + + # Verify chmod was called with 0o700 for security (at least once for the main directory) + # Flask-Session may also call chmod on subdirectories, so we check the first call + self.assertGreaterEqual(mock_chmod.call_count, 1) + first_call = mock_chmod.call_args_list[0] + self.assertEqual(first_call, call(test_temp_dir, 0o700)) + + # Verify SESSION_FILE_DIR was set + self.assertEqual(framework.app.config['SESSION_FILE_DIR'], test_temp_dir) + + # Verify warning was logged + mock_logger.warning.assert_any_call( + f"SESSION_FILE_DIR not set. Using temporary directory: {test_temp_dir}. " + "Set SESSION_FILE_DIR environment variable for production use." + ) - def test_login_with_invitation_code(self): - """invitation_code query param is forwarded to oauth.login().""" - with self.app.test_request_context(): - resp = self.client.get("/login?invitation_code=inv_abc123") + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.logger') + def test_session_file_dir_from_environment(self, mock_logger, _mock_session): + """Test that SESSION_FILE_DIR is read from environment variable.""" + os.environ['SESSION_FILE_DIR'] = self.test_session_dir + + framework = FlaskFramework() + self.created_frameworks.append(framework) + + # Verify SESSION_FILE_DIR was set from env + self.assertEqual(framework.app.config['SESSION_FILE_DIR'], self.test_session_dir) + + # Verify no warning was logged for SESSION_FILE_DIR + warning_calls = [call for call in mock_logger.warning.call_args_list + if 'SESSION_FILE_DIR' in str(call)] + self.assertEqual(len(warning_calls), 0) + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.logger') + def test_flask_session_initialization(self, mock_logger, mock_session): + """Test that Flask-Session is properly initialized.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + + # Verify Session was called with the app + mock_session.assert_called_once_with(framework.app) + + # Verify debug log was written + mock_logger.debug.assert_called_with("Flask-Session initialized with server-side storage") + + @patch('kinde_flask.framework.flask_framework.Session') + def test_session_permanent_is_false(self, _mock_session): + """Test that SESSION_PERMANENT is set to False.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + + self.assertFalse(framework.app.config['SESSION_PERMANENT']) - self.mock_oauth.login.assert_called_once() - login_options = self.mock_oauth.login.call_args[0][0] - self.assertEqual(login_options["invitation_code"], "inv_abc123") - def test_login_without_invitation_code(self): - """No invitation_code means oauth.login() gets an empty dict.""" - with self.app.test_request_context(): - resp = self.client.get("/login") +class TestFlaskFrameworkRoutes(BaseFlaskFrameworkTest): + """Test cases for Flask framework route handling.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + # Set env vars to avoid auto-generation during tests + os.environ['SECRET_KEY'] = 'test_secret_key' + os.environ['SESSION_FILE_DIR'] = tempfile.mkdtemp(prefix='test_sessions_') + self._setup_session_dir = True # Flag for cleanup + + # Mock OAuth instance + self.mock_oauth = Mock(spec=OAuth) + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.FlaskFramework._run_async') + def test_login_route_event_loop_management(self, mock_run_async, _mock_session): + """Test that login route properly uses _run_async helper.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + framework.set_oauth(self.mock_oauth) + + # Mock the OAuth login and _run_async + self.mock_oauth.login = Mock(return_value='async_login_coro') + mock_run_async.return_value = 'https://example.com/login' + + framework.start() + + # Test login route + with framework.app.test_client() as client: + response = client.get('/login') + + # Verify _run_async was called + mock_run_async.assert_called_once() + + # Verify redirect occurred + self.assertEqual(response.status_code, 302) + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.FlaskFramework._run_async') + def test_logout_route_event_loop_management(self, mock_run_async, _mock_session): + """Test that logout route properly uses _run_async helper.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + framework.set_oauth(self.mock_oauth) + + # Mock the OAuth logout and _run_async + self.mock_oauth.logout = Mock(return_value='async_logout_coro') + mock_run_async.return_value = 'https://example.com/logout' + + framework.start() + + # Test logout route + with framework.app.test_client() as client: + with client.session_transaction() as session: + session['user_id'] = 'test_user_123' + + response = client.get('/logout') + + # Verify _run_async was called + mock_run_async.assert_called_once() + + # Verify redirect occurred + self.assertEqual(response.status_code, 302) + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.FlaskFramework._run_async') + def test_register_route_event_loop_management(self, mock_run_async, _mock_session): + """Test that register route properly uses _run_async helper.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + framework.set_oauth(self.mock_oauth) + + # Mock the OAuth register and _run_async + self.mock_oauth.register = Mock(return_value='async_register_coro') + mock_run_async.return_value = 'https://example.com/register' + + framework.start() + + # Test register route + with framework.app.test_client() as client: + response = client.get('/register') + + # Verify _run_async was called + mock_run_async.assert_called_once() + + # Verify redirect occurred + self.assertEqual(response.status_code, 302) + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.asyncio.new_event_loop') + def test_event_loop_closed_on_exception(self, mock_new_event_loop, _mock_session): + """Test that event loop is closed even when an exception occurs in _run_async.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + framework.set_oauth(self.mock_oauth) + + # Create mock event loop that raises an exception + mock_loop = Mock() + mock_new_event_loop.return_value = mock_loop + mock_loop.run_until_complete = Mock(side_effect=Exception("Test exception")) + self.mock_oauth.login = Mock(return_value='async_login_coro') + + framework.start() + + # Test login route with exception + with framework.app.test_client() as client: + response = client.get('/login') + self.assertEqual(response.status_code, 500) - self.mock_oauth.login.assert_called_once() - login_options = self.mock_oauth.login.call_args[0][0] - self.assertNotIn("invitation_code", login_options) + # Verify loop.close was still called despite exception (in _run_async finally block) + mock_loop.close.assert_called() - def test_login_with_empty_invitation_code(self): - """An empty invitation_code query param is not forwarded.""" - with self.app.test_request_context(): - resp = self.client.get("/login?invitation_code=") - self.mock_oauth.login.assert_called_once() - login_options = self.mock_oauth.login.call_args[0][0] - self.assertNotIn("invitation_code", login_options) +class TestFlaskFrameworkInterface(BaseFlaskFrameworkTest): + """Test cases for FlaskFramework interface compliance.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + # Set env vars to avoid warnings during tests + os.environ['SECRET_KEY'] = 'test_secret_key' + os.environ['SESSION_FILE_DIR'] = tempfile.mkdtemp(prefix='test_sessions_') + self._setup_session_dir = True # Flag for cleanup + + @patch('kinde_flask.framework.flask_framework.Session') + def test_framework_properties(self, _mock_session): + """Test framework name and description.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + + self.assertEqual(framework.get_name(), "flask") + self.assertIn("Flask", framework.get_description()) + self.assertIn("Kinde", framework.get_description()) + + @patch('kinde_flask.framework.flask_framework.Session') + def test_get_app_returns_flask_instance(self, _mock_session): + """Test that get_app returns Flask application instance.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + + app = framework.get_app() + self.assertIsInstance(app, Flask) + + @patch('kinde_flask.framework.flask_framework.Session') + def test_start_and_stop(self, _mock_session): + """Test framework start and stop methods.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + mock_oauth = Mock(spec=OAuth) + framework.set_oauth(mock_oauth) + + # Test start + self.assertFalse(framework._initialized) + framework.start() + self.assertTrue(framework._initialized) + + # Test stop + framework.stop() + self.assertFalse(framework._initialized) + + @patch('kinde_flask.framework.flask_framework.Session') + def test_can_auto_detect(self, _mock_session): + """Test that Flask can be auto-detected when installed.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + + # Flask is installed (we're using it in tests) + self.assertTrue(framework.can_auto_detect()) + + @patch('kinde_flask.framework.flask_framework.Session') + def test_set_oauth(self, _mock_session): + """Test setting OAuth instance.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + mock_oauth = Mock(spec=OAuth) + + self.assertIsNone(framework._oauth) + framework.set_oauth(mock_oauth) + self.assertEqual(framework._oauth, mock_oauth) + + @patch('kinde_flask.framework.flask_framework.Session') + def test_user_id_management(self, _mock_session): + """Test user ID get/set functionality.""" + framework = FlaskFramework() + self.created_frameworks.append(framework) + + with framework.app.test_request_context(): + from flask import session + + # Initially no user_id + self.assertIsNone(framework.get_user_id()) + + # Set user_id in session + session['user_id'] = 'test_user_123' + self.assertEqual(framework.get_user_id(), 'test_user_123') - def test_login_redirects_to_oauth_url(self): - """The route returns a redirect to the URL from oauth.login().""" - with self.app.test_request_context(): - resp = self.client.get("/login?invitation_code=inv_xyz") - self.assertEqual(resp.status_code, 302) - self.assertEqual(resp.headers["Location"], "https://kinde.example.com/authorize") +class TestFlaskFrameworkSecurity(BaseFlaskFrameworkTest): + """Test cases for Flask framework security features.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + # Clear specific env vars for clean test environment + for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: + if key in os.environ: + del os.environ[key] + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.tempfile.mkdtemp') + @patch('kinde_flask.framework.flask_framework.os.chmod') + def test_session_directory_permissions(self, mock_chmod, mock_mkdtemp, _mock_session): + """Test that session directory is created with secure permissions (0o700).""" + test_temp_dir = '/tmp/test_kinde_sessions_xyz789' + mock_mkdtemp.return_value = test_temp_dir + + # Framework not tracked since mocks prevent real directory creation + _framework = FlaskFramework() + + # Verify chmod was called with restrictive permissions (at least once for the main directory) + # Flask-Session may also call chmod on subdirectories, so we check the first call + self.assertGreaterEqual(mock_chmod.call_count, 1) + first_call = mock_chmod.call_args_list[0] + self.assertEqual(first_call, call(test_temp_dir, 0o700)) + + @patch('kinde_flask.framework.flask_framework.Session') + def test_secret_key_length(self, _mock_session): + """Test that auto-generated SECRET_KEY has sufficient length.""" + if 'SECRET_KEY' in os.environ: + del os.environ['SECRET_KEY'] + + framework = FlaskFramework() + self.created_frameworks.append(framework) + + secret_key = framework.app.config['SECRET_KEY'] + + # secrets.token_urlsafe(32) generates a 32-byte token + # which is URL-safe base64 encoded, resulting in ~43 characters + self.assertGreater(len(secret_key), 30) -if __name__ == "__main__": +if __name__ == '__main__': unittest.main()