From 0e81beb1435b445abdf53d7fb5cf135e529765e0 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Tue, 3 Feb 2026 07:43:46 +0200 Subject: [PATCH 01/17] fix(flask,fastapi): fix session initialization and simplify examples Flask: - Initialize Flask-Session extension with Session(app) - Without this, SESSION_TYPE config was ignored and OAuth tokens were lost due to cookie size limits (~4KB) FastAPI: - Use OAuth instead of SmartOAuth to match README - Remove Management API dependency from example - Remove custom /login /logout routes that returned JSON - Update README with correct run instructions Fixes: authentication always returning false after login Refs: #129 --- kinde_fastapi/examples/README.md | 40 +-- kinde_fastapi/examples/example_app.py | 305 +++++------------------ kinde_flask/framework/flask_framework.py | 15 +- 3 files changed, 105 insertions(+), 255 deletions(-) diff --git a/kinde_fastapi/examples/README.md b/kinde_fastapi/examples/README.md index b585a26d..73eff21b 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: +``` +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..c0f84eba 100644 --- a/kinde_fastapi/examples/example_app.py +++ b/kinde_fastapi/examples/example_app.py @@ -1,37 +1,42 @@ """ -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 .session import InMemorySessionMiddleware -from pathlib import Path import os 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__) @@ -42,26 +47,20 @@ 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): @@ -69,236 +68,64 @@ async def home(request: Request): 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() + return f""" -

Configuration Error

-

Missing required environment variables for management client.

- Logout +

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

+

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 - - - """ + logger.error(f"Error getting user info: {e}") + return f""" + + +

Error

+

Failed to get user information: {str(e)}

+ 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. """ - 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"} - - 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}" - } - 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)} - -@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 {"error": "Not authenticated", "redirect": "/login"} + user = kinde_oauth.get_user_info() return { - "message": "SmartOAuth Demo Results", - "results": results, - "note": "SmartOAuth automatically detected the async context and used appropriate methods." + "message": "This is a protected route", + "user": user.get('email') } -@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/framework/flask_framework.py b/kinde_flask/framework/flask_framework.py index 2b2b9246..a98b5217 100644 --- a/kinde_flask/framework/flask_framework.py +++ b/kinde_flask/framework/flask_framework.py @@ -1,5 +1,6 @@ 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 @@ -7,10 +8,13 @@ import uuid import asyncio import nest_asyncio +import logging if TYPE_CHECKING: from flask import Request +logger = logging.getLogger(__name__) + class FlaskFramework(FrameworkInterface): """ Flask framework implementation. @@ -29,10 +33,17 @@ def __init__(self, app: Optional[Flask] = None): self._initialized = False self._oauth = None - # Configure Flask session + # Configure Flask session for server-side storage + # This is required because OAuth tokens can exceed cookie size limits (~4KB) self.app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key') - self.app.config['SESSION_TYPE'] = 'filesystem' + self.app.config['SESSION_TYPE'] = os.getenv('SESSION_TYPE', 'filesystem') self.app.config['SESSION_PERMANENT'] = False + self.app.config['SESSION_FILE_DIR'] = os.getenv('SESSION_FILE_DIR', '/tmp/flask_sessions') + + # 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") # Enable nested event loops nest_asyncio.apply() From ea537c37c47c1ff370cbbe68bb63df0bb69ce075 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Mon, 9 Feb 2026 07:03:28 +0200 Subject: [PATCH 02/17] fix(flask,fastapi): fix .env loading and remove nest_asyncio dependency Load .env relative to the example script's directory so it is found regardless of the working directory. Remove nest_asyncio (incompatible with uvloop) and use asyncio.new_event_loop() consistently in all Flask route handlers. --- kinde_fastapi/examples/example_app.py | 4 ++-- kinde_flask/examples/example_app.py | 4 ++-- kinde_flask/framework/flask_framework.py | 30 ++++++++++++++---------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/kinde_fastapi/examples/example_app.py b/kinde_fastapi/examples/example_app.py index c0f84eba..ac651b35 100644 --- a/kinde_fastapi/examples/example_app.py +++ b/kinde_fastapi/examples/example_app.py @@ -40,8 +40,8 @@ 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") 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 a98b5217..b917cefc 100644 --- a/kinde_flask/framework/flask_framework.py +++ b/kinde_flask/framework/flask_framework.py @@ -7,7 +7,6 @@ import os import uuid import asyncio -import nest_asyncio import logging if TYPE_CHECKING: @@ -45,8 +44,6 @@ def __init__(self, app: Optional[Flask] = None): Session(self.app) logger.debug("Flask-Session initialized with server-side storage") - # Enable nested event loops - nest_asyncio.apply() def get_name(self) -> str: """ @@ -137,9 +134,12 @@ def _register_kinde_routes(self) -> None: @self.app.route('/login') def login(): """Redirect to Kinde login page.""" - loop = asyncio.get_event_loop() - login_url = loop.run_until_complete(self._oauth.login()) - return redirect(login_url) + loop = asyncio.new_event_loop() + try: + login_url = loop.run_until_complete(self._oauth.login()) + return redirect(login_url) + finally: + loop.close() # Callback route @self.app.route("/callback") @@ -226,17 +226,23 @@ 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)) - return redirect(logout_url) + loop = asyncio.new_event_loop() + try: + logout_url = loop.run_until_complete(self._oauth.logout(user_id)) + return redirect(logout_url) + finally: + loop.close() # 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()) - return redirect(register_url) + loop = asyncio.new_event_loop() + try: + register_url = loop.run_until_complete(self._oauth.register()) + return redirect(register_url) + finally: + loop.close() # User info route @self.app.route('/user') From 38cb83345c2aeda1683892971a332f2e9676af98 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Mon, 9 Feb 2026 19:01:14 +0200 Subject: [PATCH 03/17] Fix security vulnerabilities and improve FastAPI/Flask examples --- kinde_fastapi/examples/README.md | 2 +- kinde_fastapi/examples/example_app.py | 22 +++++++++++++++------- kinde_flask/framework/flask_framework.py | 23 +++++++++++++++++++++-- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/kinde_fastapi/examples/README.md b/kinde_fastapi/examples/README.md index 73eff21b..4cfc6550 100644 --- a/kinde_fastapi/examples/README.md +++ b/kinde_fastapi/examples/README.md @@ -15,7 +15,7 @@ pip install fastapi uvicorn python-multipart python-dotenv - Copy your client ID and client secret 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 diff --git a/kinde_fastapi/examples/example_app.py b/kinde_fastapi/examples/example_app.py index ac651b35..b1d52d79 100644 --- a/kinde_fastapi/examples/example_app.py +++ b/kinde_fastapi/examples/example_app.py @@ -31,9 +31,10 @@ """ from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from .session import InMemorySessionMiddleware import os +import html from dotenv import load_dotenv import logging from kinde_sdk.auth.oauth import OAuth @@ -70,16 +71,21 @@ async def home(request: Request): if kinde_oauth.is_authenticated(): 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""" -

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

+

Welcome, {email}!

You are logged in.

User Information:

    -
  • Email: {user.get('email', 'N/A')}
  • -
  • Name: {user.get('given_name', '')} {user.get('family_name', '')}
  • -
  • ID: {user.get('sub', 'N/A')}
  • +
  • Email: {email_display}
  • +
  • Name: {given_name} {family_name}
  • +
  • ID: {user_id}

View Full User Info (JSON)

@@ -88,12 +94,13 @@ async def home(request: Request): """ except Exception as e: + error_msg = html.escape(str(e)) logger.error(f"Error getting user info: {e}") return f"""

Error

-

Failed to get user information: {str(e)}

+

Failed to get user information: {error_msg}

Logout @@ -115,9 +122,10 @@ async def home(request: Request): async def protected_route(): """ Example of a protected route that requires authentication. + Redirects to login if not authenticated. """ if not kinde_oauth.is_authenticated(): - return {"error": "Not authenticated", "redirect": "/login"} + return RedirectResponse("/login") user = kinde_oauth.get_user_info() return { diff --git a/kinde_flask/framework/flask_framework.py b/kinde_flask/framework/flask_framework.py index b917cefc..835fa447 100644 --- a/kinde_flask/framework/flask_framework.py +++ b/kinde_flask/framework/flask_framework.py @@ -8,6 +8,8 @@ import uuid import asyncio import logging +import secrets +import tempfile if TYPE_CHECKING: from flask import Request @@ -34,10 +36,27 @@ def __init__(self, app: Optional[Flask] = None): # Configure Flask session for server-side storage # This is required because OAuth tokens can exceed cookie size limits (~4KB) - self.app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'your-secret-key') + 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 - self.app.config['SESSION_FILE_DIR'] = os.getenv('SESSION_FILE_DIR', '/tmp/flask_sessions') + + 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 From e37cf8f2c72c43fde9595e5e5e14f7bdfee55133 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Tue, 10 Feb 2026 05:35:59 +0200 Subject: [PATCH 04/17] fix: error handling added to protected fastapi example route --- kinde_fastapi/examples/example_app.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/kinde_fastapi/examples/example_app.py b/kinde_fastapi/examples/example_app.py index b1d52d79..52b6a8aa 100644 --- a/kinde_fastapi/examples/example_app.py +++ b/kinde_fastapi/examples/example_app.py @@ -127,11 +127,15 @@ async def protected_route(): if not kinde_oauth.is_authenticated(): return RedirectResponse("/login") - user = kinde_oauth.get_user_info() - return { - "message": "This is a protected route", - "user": user.get('email') - } + try: + user = kinde_oauth.get_user_info() + return { + "message": "This is a protected route", + "user": user.get('email') + } + except Exception as e: + logger.error(f"Error getting user info in protected route: {e}") + return RedirectResponse("/login") if __name__ == "__main__": From 567eb43e5c583520a608aca2e62136ada3e04b29 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Tue, 10 Feb 2026 07:27:53 +0200 Subject: [PATCH 05/17] test: add comprehensive test coverage for Flask framework --- kinde_fastapi/examples/example_app.py | 14 +- .../testv2_framework/test_flask_framework.py | 428 ++++++++++++++++++ 2 files changed, 437 insertions(+), 5 deletions(-) create mode 100644 testv2/testv2_framework/test_flask_framework.py diff --git a/kinde_fastapi/examples/example_app.py b/kinde_fastapi/examples/example_app.py index b1d52d79..52b6a8aa 100644 --- a/kinde_fastapi/examples/example_app.py +++ b/kinde_fastapi/examples/example_app.py @@ -127,11 +127,15 @@ async def protected_route(): if not kinde_oauth.is_authenticated(): return RedirectResponse("/login") - user = kinde_oauth.get_user_info() - return { - "message": "This is a protected route", - "user": user.get('email') - } + try: + user = kinde_oauth.get_user_info() + return { + "message": "This is a protected route", + "user": user.get('email') + } + except Exception as e: + logger.error(f"Error getting user info in protected route: {e}") + return RedirectResponse("/login") if __name__ == "__main__": diff --git a/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py new file mode 100644 index 00000000..4193eb82 --- /dev/null +++ b/testv2/testv2_framework/test_flask_framework.py @@ -0,0 +1,428 @@ +import unittest +import os +import tempfile +import shutil +import logging +from unittest.mock import Mock, patch, MagicMock, call +from flask import Flask +from kinde_flask.framework.flask_framework import FlaskFramework +from kinde_sdk.auth.oauth import OAuth + + +class TestFlaskFramework(unittest.TestCase): + """Test cases for FlaskFramework configuration.""" + + def setUp(self): + """Set up test fixtures.""" + # Store original env vars + self.original_env = {} + for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: + if key in os.environ: + self.original_env[key] = os.environ[key] + del os.environ[key] + + # Create a temporary directory for test sessions + self.test_session_dir = tempfile.mkdtemp(prefix='test_flask_sessions_') + + def tearDown(self): + """Clean up after tests.""" + # Restore original env vars + for key, value in self.original_env.items(): + os.environ[key] = value + + # Clean up test session directory + if os.path.exists(self.test_session_dir): + shutil.rmtree(self.test_session_dir) + + @patch('kinde_flask.framework.flask_framework.logger') + def test_secret_key_auto_generation_with_warning(self, mock_logger): + """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() + + # 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.logger') + def test_secret_key_from_environment(self, mock_logger): + """Test that SECRET_KEY is read from environment variable.""" + test_secret = "my_test_secret_key_12345" + os.environ['SECRET_KEY'] = test_secret + + framework = FlaskFramework() + + # 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.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.""" + # Use 'filesystem' instead of 'redis' since redis module is not installed in test env + os.environ['SESSION_TYPE'] = 'filesystem' + + framework = FlaskFramework() + + self.assertEqual(framework.app.config['SESSION_TYPE'], 'filesystem') + + @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 + assert mock_chmod.call_count >= 1 + first_call = mock_chmod.call_args_list[0] + assert 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." + ) + + @patch('kinde_flask.framework.flask_framework.logger') + def test_session_file_dir_from_environment(self, mock_logger): + """Test that SESSION_FILE_DIR is read from environment variable.""" + os.environ['SESSION_FILE_DIR'] = self.test_session_dir + + framework = FlaskFramework() + + # 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() + + # 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.assertFalse(framework.app.config['SESSION_PERMANENT']) + + +class TestFlaskFrameworkRoutes(unittest.TestCase): + """Test cases for Flask framework route handling.""" + + def setUp(self): + """Set up test fixtures.""" + self.original_env = {} + for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: + if key in os.environ: + self.original_env[key] = os.environ[key] + + # 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_') + + # Mock OAuth instance + self.mock_oauth = Mock(spec=OAuth) + + def tearDown(self): + """Clean up after tests.""" + # Restore original env vars + for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: + if key in os.environ: + del os.environ[key] + for key, value in self.original_env.items(): + os.environ[key] = value + + # Clean up test session directory + session_dir = os.environ.get('SESSION_FILE_DIR') + if session_dir and os.path.exists(session_dir): + shutil.rmtree(session_dir) + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.asyncio.new_event_loop') + def test_login_route_event_loop_management(self, mock_new_event_loop, mock_session): + """Test that login route properly creates and closes event loop.""" + framework = FlaskFramework() + framework.set_oauth(self.mock_oauth) + + # Create mock event loop + mock_loop = Mock() + mock_new_event_loop.return_value = mock_loop + self.mock_oauth.login = Mock(return_value='https://example.com/login') + mock_loop.run_until_complete = Mock(return_value='https://example.com/login') + + framework.start() + + # Test login route + with framework.app.test_client() as client: + response = client.get('/login') + + # Verify event loop was created + mock_new_event_loop.assert_called() + + # Verify loop.run_until_complete was called + mock_loop.run_until_complete.assert_called() + + # Verify loop.close was called + mock_loop.close.assert_called() + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.asyncio.new_event_loop') + def test_logout_route_event_loop_management(self, mock_new_event_loop, mock_session): + """Test that logout route properly creates and closes event loop.""" + framework = FlaskFramework() + framework.set_oauth(self.mock_oauth) + + # Create mock event loop + mock_loop = Mock() + mock_new_event_loop.return_value = mock_loop + self.mock_oauth.logout = Mock(return_value='https://example.com/logout') + mock_loop.run_until_complete = Mock(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 event loop was created + mock_new_event_loop.assert_called() + + # Verify loop.run_until_complete was called + mock_loop.run_until_complete.assert_called() + + # Verify loop.close was called + mock_loop.close.assert_called() + + @patch('kinde_flask.framework.flask_framework.Session') + @patch('kinde_flask.framework.flask_framework.asyncio.new_event_loop') + def test_register_route_event_loop_management(self, mock_new_event_loop, mock_session): + """Test that register route properly creates and closes event loop.""" + framework = FlaskFramework() + framework.set_oauth(self.mock_oauth) + + # Create mock event loop + mock_loop = Mock() + mock_new_event_loop.return_value = mock_loop + self.mock_oauth.register = Mock(return_value='https://example.com/register') + mock_loop.run_until_complete = Mock(return_value='https://example.com/register') + + framework.start() + + # Test register route + with framework.app.test_client() as client: + response = client.get('/register') + + # Verify event loop was created + mock_new_event_loop.assert_called() + + # Verify loop.run_until_complete was called + mock_loop.run_until_complete.assert_called() + + # Verify loop.close was called + mock_loop.close.assert_called() + + @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.""" + framework = FlaskFramework() + 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")) + + framework.start() + + # Test login route with exception + with framework.app.test_client() as client: + try: + response = client.get('/login') + except Exception: + pass # Expected to raise + + # Verify loop.close was still called despite exception + mock_loop.close.assert_called() + + +class TestFlaskFrameworkInterface(unittest.TestCase): + """Test cases for FlaskFramework interface compliance.""" + + def setUp(self): + """Set up test fixtures.""" + # 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_') + + def tearDown(self): + """Clean up after tests.""" + session_dir = os.environ.get('SESSION_FILE_DIR') + if session_dir and os.path.exists(session_dir): + shutil.rmtree(session_dir) + + for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: + if key in os.environ: + del os.environ[key] + + def test_framework_properties(self): + """Test framework name and description.""" + framework = FlaskFramework() + + self.assertEqual(framework.get_name(), "flask") + self.assertIn("Flask", framework.get_description()) + self.assertIn("Kinde", framework.get_description()) + + def test_get_app_returns_flask_instance(self): + """Test that get_app returns Flask application instance.""" + framework = FlaskFramework() + + app = framework.get_app() + self.assertIsInstance(app, Flask) + + def test_start_and_stop(self): + """Test framework start and stop methods.""" + framework = FlaskFramework() + 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() + + # Flask is installed (we're using it in tests) + self.assertTrue(framework.can_auto_detect()) + + def test_set_oauth(self): + """Test setting OAuth instance.""" + framework = FlaskFramework() + mock_oauth = Mock(spec=OAuth) + + self.assertIsNone(framework._oauth) + framework.set_oauth(mock_oauth) + self.assertEqual(framework._oauth, mock_oauth) + + def test_user_id_management(self): + """Test user ID get/set functionality.""" + framework = FlaskFramework() + + 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') + + +class TestFlaskFrameworkSecurity(unittest.TestCase): + """Test cases for Flask framework security features.""" + + def setUp(self): + """Set up test fixtures.""" + # Clear env vars + for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: + if key in os.environ: + del os.environ[key] + + def tearDown(self): + """Clean up after tests.""" + pass + + @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 = 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 + assert mock_chmod.call_count >= 1 + first_call = mock_chmod.call_args_list[0] + assert first_call == call(test_temp_dir, 0o700) + + def test_secret_key_length(self): + """Test that auto-generated SECRET_KEY has sufficient length.""" + if 'SECRET_KEY' in os.environ: + del os.environ['SECRET_KEY'] + + framework = FlaskFramework() + + 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__': + unittest.main() From 7dcaa3fbaef752c9cef5395ed1524e508ff9013a Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Wed, 11 Feb 2026 05:46:45 +0200 Subject: [PATCH 06/17] test: improved flask framework tests --- .../testv2_framework/test_flask_framework.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py index 4193eb82..0ad5b1f2 100644 --- a/testv2/testv2_framework/test_flask_framework.py +++ b/testv2/testv2_framework/test_flask_framework.py @@ -2,8 +2,7 @@ import os import tempfile import shutil -import logging -from unittest.mock import Mock, patch, MagicMock, call +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 @@ -82,12 +81,11 @@ def test_session_type_default(self, mock_session): @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.""" - # Use 'filesystem' instead of 'redis' since redis module is not installed in test env - os.environ['SESSION_TYPE'] = 'filesystem' + os.environ['SESSION_TYPE'] = 'test-session-type' framework = FlaskFramework() - self.assertEqual(framework.app.config['SESSION_TYPE'], 'filesystem') + 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') @@ -175,6 +173,7 @@ def setUp(self): def tearDown(self): """Clean up after tests.""" + session_dir = os.environ.get('SESSION_FILE_DIR') # Restore original env vars for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: if key in os.environ: @@ -183,7 +182,6 @@ def tearDown(self): os.environ[key] = value # Clean up test session directory - session_dir = os.environ.get('SESSION_FILE_DIR') if session_dir and os.path.exists(session_dir): shutil.rmtree(session_dir) @@ -290,11 +288,9 @@ def test_event_loop_closed_on_exception(self, mock_new_event_loop, mock_session) # Test login route with exception with framework.app.test_client() as client: - try: - response = client.get('/login') - except Exception: - pass # Expected to raise - + response = client.get('/login') + self.assertEqual(response.status_code, 500) + # Verify loop.close was still called despite exception mock_loop.close.assert_called() @@ -389,10 +385,12 @@ def setUp(self): for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: if key in os.environ: del os.environ[key] - + def tearDown(self): """Clean up after tests.""" - pass + 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') @@ -423,6 +421,11 @@ def test_secret_key_length(self): # which is URL-safe base64 encoded, resulting in ~43 characters self.assertGreater(len(secret_key), 30) + # Clean up auto-generated session directory + session_dir = framework.app.config.get('SESSION_FILE_DIR') + if session_dir and os.path.exists(session_dir): + shutil.rmtree(session_dir) + if __name__ == '__main__': unittest.main() From fed08ee669f3f79d68208f83d952eb8f1de751b4 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Wed, 11 Feb 2026 06:01:25 +0200 Subject: [PATCH 07/17] test: fix resource leaks in Flask framework test tearDown --- .../testv2_framework/test_flask_framework.py | 174 ++++++++++++++---- 1 file changed, 142 insertions(+), 32 deletions(-) diff --git a/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py index 0ad5b1f2..e6395ced 100644 --- a/testv2/testv2_framework/test_flask_framework.py +++ b/testv2/testv2_framework/test_flask_framework.py @@ -13,25 +13,47 @@ class TestFlaskFramework(unittest.TestCase): def setUp(self): """Set up test fixtures.""" - # Store original env vars - self.original_env = {} + # Store original env vars - snapshot ALL env vars + self.original_env = dict(os.environ) + + # Clear specific keys for clean test environment for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: if key in os.environ: - self.original_env[key] = os.environ[key] del os.environ[key] # Create a temporary directory for test sessions self.test_session_dir = tempfile.mkdtemp(prefix='test_flask_sessions_') + + # Track created frameworks for cleanup + self.created_frameworks = [] def tearDown(self): """Clean up after tests.""" - # Restore original env vars - for key, value in self.original_env.items(): - os.environ[key] = value + # 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): + try: + shutil.rmtree(session_dir) + except Exception: + pass # Ignore cleanup errors # Clean up test session directory - if os.path.exists(self.test_session_dir): + if hasattr(self, 'test_session_dir') and os.path.exists(self.test_session_dir): shutil.rmtree(self.test_session_dir) + + # 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 @patch('kinde_flask.framework.flask_framework.logger') def test_secret_key_auto_generation_with_warning(self, mock_logger): @@ -41,6 +63,7 @@ def test_secret_key_auto_generation_with_warning(self, mock_logger): 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')) @@ -59,6 +82,7 @@ def test_secret_key_from_environment(self, mock_logger): 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) @@ -75,6 +99,7 @@ def test_session_type_default(self, mock_session): del os.environ['SESSION_TYPE'] framework = FlaskFramework() + self.created_frameworks.append(framework) self.assertEqual(framework.app.config['SESSION_TYPE'], 'filesystem') @@ -84,6 +109,7 @@ def test_session_type_from_environment(self, mock_session): os.environ['SESSION_TYPE'] = 'test-session-type' framework = FlaskFramework() + self.created_frameworks.append(framework) self.assertEqual(framework.app.config['SESSION_TYPE'], 'test-session-type') @@ -125,6 +151,7 @@ def test_session_file_dir_from_environment(self, mock_logger): 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) @@ -139,6 +166,7 @@ def test_session_file_dir_from_environment(self, mock_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) @@ -150,6 +178,7 @@ def test_flask_session_initialization(self, mock_logger, mock_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']) @@ -159,10 +188,8 @@ class TestFlaskFrameworkRoutes(unittest.TestCase): def setUp(self): """Set up test fixtures.""" - self.original_env = {} - for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: - if key in os.environ: - self.original_env[key] = os.environ[key] + # Store original env vars - snapshot ALL env vars + self.original_env = dict(os.environ) # Set env vars to avoid auto-generation during tests os.environ['SECRET_KEY'] = 'test_secret_key' @@ -170,26 +197,50 @@ def setUp(self): # Mock OAuth instance self.mock_oauth = Mock(spec=OAuth) + + # Track created frameworks for cleanup + self.created_frameworks = [] def tearDown(self): """Clean up after tests.""" + # Get session dir before clearing env session_dir = os.environ.get('SESSION_FILE_DIR') - # Restore original env vars - for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: - if key in os.environ: - del os.environ[key] - for key, value in self.original_env.items(): - os.environ[key] = value - # Clean up test session directory + # Clean up any framework-created session directories + for framework in self.created_frameworks: + if hasattr(framework, 'app') and framework.app: + fw_session_dir = framework.app.config.get('SESSION_FILE_DIR') + if fw_session_dir and os.path.exists(fw_session_dir): + try: + shutil.rmtree(fw_session_dir) + except Exception: + pass # Ignore cleanup errors + + # Clean up test session directory from setUp if session_dir and os.path.exists(session_dir): - shutil.rmtree(session_dir) + try: + shutil.rmtree(session_dir) + except Exception: + pass + + # 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 @patch('kinde_flask.framework.flask_framework.Session') @patch('kinde_flask.framework.flask_framework.asyncio.new_event_loop') def test_login_route_event_loop_management(self, mock_new_event_loop, mock_session): """Test that login route properly creates and closes event loop.""" framework = FlaskFramework() + self.created_frameworks.append(framework) framework.set_oauth(self.mock_oauth) # Create mock event loop @@ -218,6 +269,7 @@ def test_login_route_event_loop_management(self, mock_new_event_loop, mock_sessi def test_logout_route_event_loop_management(self, mock_new_event_loop, mock_session): """Test that logout route properly creates and closes event loop.""" framework = FlaskFramework() + self.created_frameworks.append(framework) framework.set_oauth(self.mock_oauth) # Create mock event loop @@ -249,6 +301,7 @@ def test_logout_route_event_loop_management(self, mock_new_event_loop, mock_sess def test_register_route_event_loop_management(self, mock_new_event_loop, mock_session): """Test that register route properly creates and closes event loop.""" framework = FlaskFramework() + self.created_frameworks.append(framework) framework.set_oauth(self.mock_oauth) # Create mock event loop @@ -277,6 +330,7 @@ def test_register_route_event_loop_management(self, mock_new_event_loop, mock_se 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.""" framework = FlaskFramework() + self.created_frameworks.append(framework) framework.set_oauth(self.mock_oauth) # Create mock event loop that raises an exception @@ -300,23 +354,54 @@ class TestFlaskFrameworkInterface(unittest.TestCase): def setUp(self): """Set up test fixtures.""" + # Store original env vars - snapshot ALL env vars + self.original_env = dict(os.environ) + # 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_') + + # Track created frameworks for cleanup + self.created_frameworks = [] def tearDown(self): """Clean up after tests.""" + # Get session dir before clearing env session_dir = os.environ.get('SESSION_FILE_DIR') + + # Clean up any framework-created session directories + for framework in self.created_frameworks: + if hasattr(framework, 'app') and framework.app: + fw_session_dir = framework.app.config.get('SESSION_FILE_DIR') + if fw_session_dir and os.path.exists(fw_session_dir): + try: + shutil.rmtree(fw_session_dir) + except Exception: + pass # Ignore cleanup errors + + # Clean up test session directory from setUp if session_dir and os.path.exists(session_dir): - shutil.rmtree(session_dir) + try: + shutil.rmtree(session_dir) + except Exception: + pass - for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: - if key in os.environ: - del os.environ[key] + # 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 def test_framework_properties(self): """Test framework name and description.""" framework = FlaskFramework() + self.created_frameworks.append(framework) self.assertEqual(framework.get_name(), "flask") self.assertIn("Flask", framework.get_description()) @@ -325,6 +410,7 @@ def test_framework_properties(self): def test_get_app_returns_flask_instance(self): """Test that get_app returns Flask application instance.""" framework = FlaskFramework() + self.created_frameworks.append(framework) app = framework.get_app() self.assertIsInstance(app, Flask) @@ -332,6 +418,7 @@ def test_get_app_returns_flask_instance(self): def test_start_and_stop(self): """Test framework start and stop methods.""" framework = FlaskFramework() + self.created_frameworks.append(framework) mock_oauth = Mock(spec=OAuth) framework.set_oauth(mock_oauth) @@ -348,6 +435,7 @@ def test_start_and_stop(self): 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()) @@ -355,6 +443,7 @@ def test_can_auto_detect(self, mock_session): def test_set_oauth(self): """Test setting OAuth instance.""" framework = FlaskFramework() + self.created_frameworks.append(framework) mock_oauth = Mock(spec=OAuth) self.assertIsNone(framework._oauth) @@ -364,6 +453,7 @@ def test_set_oauth(self): def test_user_id_management(self): """Test user ID get/set functionality.""" framework = FlaskFramework() + self.created_frameworks.append(framework) with framework.app.test_request_context(): from flask import session @@ -381,16 +471,40 @@ class TestFlaskFrameworkSecurity(unittest.TestCase): def setUp(self): """Set up test fixtures.""" - # Clear env vars + # Store original env vars - snapshot ALL env vars + self.original_env = dict(os.environ) + + # 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] + + # Track created frameworks for cleanup + self.created_frameworks = [] def tearDown(self): """Clean up after tests.""" - for key in ['SECRET_KEY', 'SESSION_TYPE', 'SESSION_FILE_DIR']: - if key in os.environ: - del os.environ[key] + # 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): + try: + shutil.rmtree(session_dir) + except Exception: + pass # Ignore cleanup errors + + # 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 @patch('kinde_flask.framework.flask_framework.Session') @patch('kinde_flask.framework.flask_framework.tempfile.mkdtemp') @@ -414,6 +528,7 @@ def test_secret_key_length(self): del os.environ['SECRET_KEY'] framework = FlaskFramework() + self.created_frameworks.append(framework) secret_key = framework.app.config['SECRET_KEY'] @@ -421,11 +536,6 @@ def test_secret_key_length(self): # which is URL-safe base64 encoded, resulting in ~43 characters self.assertGreater(len(secret_key), 30) - # Clean up auto-generated session directory - session_dir = framework.app.config.get('SESSION_FILE_DIR') - if session_dir and os.path.exists(session_dir): - shutil.rmtree(session_dir) - if __name__ == '__main__': unittest.main() From 5cc48b2dcae7a8c5b594c29f95483aabb1c5e491 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 12 Feb 2026 05:37:15 +0200 Subject: [PATCH 08/17] fix: remove duplicate login() call in Flask framework --- kinde_flask/framework/flask_framework.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/kinde_flask/framework/flask_framework.py b/kinde_flask/framework/flask_framework.py index f4fcabd6..3b617937 100644 --- a/kinde_flask/framework/flask_framework.py +++ b/kinde_flask/framework/flask_framework.py @@ -153,23 +153,21 @@ def _register_kinde_routes(self) -> None: @self.app.route('/login') def login(): """Redirect to Kinde login page.""" + # Build login options from query parameters + login_options = {} + + # Check for invitation_code in query parameters + invitation_code = request.args.get('invitation_code') + if invitation_code: + login_options['invitation_code'] = invitation_code + loop = asyncio.new_event_loop() try: - login_url = loop.run_until_complete(self._oauth.login()) - - # Build login options from query parameters - login_options = {} - - # Check for invitation_code in query parameters - invitation_code = request.args.get('invitation_code') - if invitation_code: - login_options['invitation_code'] = invitation_code - login_url = loop.run_until_complete(self._oauth.login(login_options)) return redirect(login_url) finally: loop.close() - + # Callback route @self.app.route("/callback") def callback(): From 2e3aec9a52871190d8dbfbb00a72f8be83da2c20 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 12 Feb 2026 06:10:53 +0200 Subject: [PATCH 09/17] refactor: improve async handling and cleanup in Flask framework --- kinde_flask/framework/flask_framework.py | 59 ++++++++++++------------ 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/kinde_flask/framework/flask_framework.py b/kinde_flask/framework/flask_framework.py index 3b617937..9a52feb8 100644 --- a/kinde_flask/framework/flask_framework.py +++ b/kinde_flask/framework/flask_framework.py @@ -145,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, + preventing subtle async bugs when OAuth internals call asyncio.get_event_loop(). + + Args: + coro: The coroutine to run + + Returns: + The result of the coroutine + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(coro) + finally: + loop.close() + def _register_kinde_routes(self) -> None: """ Register all Kinde-specific routes with the Flask application. @@ -161,12 +182,8 @@ def login(): if invitation_code: login_options['invitation_code'] = invitation_code - loop = asyncio.new_event_loop() - try: - login_url = loop.run_until_complete(self._oauth.login(login_options)) - return redirect(login_url) - finally: - loop.close() + login_url = self._run_async(self._oauth.login(login_options)) + return redirect(login_url) # Callback route @self.app.route("/callback") @@ -233,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 @@ -253,23 +267,15 @@ def logout(): """Logout the user and redirect to Kinde logout page.""" user_id = session.get('user_id') session.clear() - loop = asyncio.new_event_loop() - try: - logout_url = loop.run_until_complete(self._oauth.logout(user_id)) - return redirect(logout_url) - finally: - loop.close() + 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.new_event_loop() - try: - register_url = loop.run_until_complete(self._oauth.register()) - return redirect(register_url) - finally: - loop.close() + register_url = self._run_async(self._oauth.register()) + return redirect(register_url) # User info route @self.app.route('/user') @@ -277,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: From d30cb9c1943c540031f032190fe645fc74566847 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 12 Feb 2026 06:21:38 +0200 Subject: [PATCH 10/17] refactor: improve async handling in Flask framework and update tests --- .../testv2_framework/test_flask_framework.py | 83 ++++++++----------- 1 file changed, 35 insertions(+), 48 deletions(-) diff --git a/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py index e6395ced..f0ae48d8 100644 --- a/testv2/testv2_framework/test_flask_framework.py +++ b/testv2/testv2_framework/test_flask_framework.py @@ -236,18 +236,16 @@ def tearDown(self): os.environ[key] = value @patch('kinde_flask.framework.flask_framework.Session') - @patch('kinde_flask.framework.flask_framework.asyncio.new_event_loop') - def test_login_route_event_loop_management(self, mock_new_event_loop, mock_session): - """Test that login route properly creates and closes event loop.""" + @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) - # Create mock event loop - mock_loop = Mock() - mock_new_event_loop.return_value = mock_loop - self.mock_oauth.login = Mock(return_value='https://example.com/login') - mock_loop.run_until_complete = Mock(return_value='https://example.com/login') + # 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() @@ -255,28 +253,23 @@ def test_login_route_event_loop_management(self, mock_new_event_loop, mock_sessi with framework.app.test_client() as client: response = client.get('/login') - # Verify event loop was created - mock_new_event_loop.assert_called() - - # Verify loop.run_until_complete was called - mock_loop.run_until_complete.assert_called() + # Verify _run_async was called + mock_run_async.assert_called_once() - # Verify loop.close was called - mock_loop.close.assert_called() + # 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_logout_route_event_loop_management(self, mock_new_event_loop, mock_session): - """Test that logout route properly creates and closes event loop.""" + @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) - # Create mock event loop - mock_loop = Mock() - mock_new_event_loop.return_value = mock_loop - self.mock_oauth.logout = Mock(return_value='https://example.com/logout') - mock_loop.run_until_complete = Mock(return_value='https://example.com/logout') + # 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() @@ -287,28 +280,23 @@ def test_logout_route_event_loop_management(self, mock_new_event_loop, mock_sess response = client.get('/logout') - # Verify event loop was created - mock_new_event_loop.assert_called() + # Verify _run_async was called + mock_run_async.assert_called_once() - # Verify loop.run_until_complete was called - mock_loop.run_until_complete.assert_called() - - # Verify loop.close was called - mock_loop.close.assert_called() + # 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_register_route_event_loop_management(self, mock_new_event_loop, mock_session): - """Test that register route properly creates and closes event loop.""" + @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) - # Create mock event loop - mock_loop = Mock() - mock_new_event_loop.return_value = mock_loop - self.mock_oauth.register = Mock(return_value='https://example.com/register') - mock_loop.run_until_complete = Mock(return_value='https://example.com/register') + # 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() @@ -316,19 +304,17 @@ def test_register_route_event_loop_management(self, mock_new_event_loop, mock_se with framework.app.test_client() as client: response = client.get('/register') - # Verify event loop was created - mock_new_event_loop.assert_called() - - # Verify loop.run_until_complete was called - mock_loop.run_until_complete.assert_called() + # Verify _run_async was called + mock_run_async.assert_called_once() - # Verify loop.close was called - mock_loop.close.assert_called() + # 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.""" + @patch('kinde_flask.framework.flask_framework.asyncio.set_event_loop') + def test_event_loop_closed_on_exception(self, mock_set_event_loop, 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) @@ -337,6 +323,7 @@ def test_event_loop_closed_on_exception(self, mock_new_event_loop, mock_session) 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() @@ -345,7 +332,7 @@ def test_event_loop_closed_on_exception(self, mock_new_event_loop, mock_session) response = client.get('/login') self.assertEqual(response.status_code, 500) - # Verify loop.close was still called despite exception + # Verify loop.close was still called despite exception (in _run_async finally block) mock_loop.close.assert_called() From 17d9291bcddedcf50728a63d41d1b673f9ba8b82 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 12 Feb 2026 06:36:30 +0200 Subject: [PATCH 11/17] refactor: improve async event loop handling and cleanup in Flask framework --- kinde_flask/framework/flask_framework.py | 6 +++--- testv2/testv2_framework/test_flask_framework.py | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/kinde_flask/framework/flask_framework.py b/kinde_flask/framework/flask_framework.py index 9a52feb8..2d69a15a 100644 --- a/kinde_flask/framework/flask_framework.py +++ b/kinde_flask/framework/flask_framework.py @@ -150,8 +150,8 @@ 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, - preventing subtle async bugs when OAuth internals call asyncio.get_event_loop(). + 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 @@ -160,10 +160,10 @@ def _run_async(coro): The result of the coroutine """ loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) try: return loop.run_until_complete(coro) finally: + asyncio.set_event_loop(None) loop.close() def _register_kinde_routes(self) -> None: diff --git a/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py index f0ae48d8..cf191aa0 100644 --- a/testv2/testv2_framework/test_flask_framework.py +++ b/testv2/testv2_framework/test_flask_framework.py @@ -312,8 +312,7 @@ def test_register_route_event_loop_management(self, mock_run_async, mock_session @patch('kinde_flask.framework.flask_framework.Session') @patch('kinde_flask.framework.flask_framework.asyncio.new_event_loop') - @patch('kinde_flask.framework.flask_framework.asyncio.set_event_loop') - def test_event_loop_closed_on_exception(self, mock_set_event_loop, mock_new_event_loop, mock_session): + 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) From eafc8e13270f71b85060f9d4c3317e5b305373d5 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 12 Feb 2026 06:47:07 +0200 Subject: [PATCH 12/17] fix: added flask-session to pyproject.toml, fixed test asserts --- kinde_flask/pyproject.toml | 1 + testv2/testv2_framework/test_flask_framework.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) 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/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py index cf191aa0..c3d70c61 100644 --- a/testv2/testv2_framework/test_flask_framework.py +++ b/testv2/testv2_framework/test_flask_framework.py @@ -132,9 +132,9 @@ def test_session_file_dir_auto_generation_with_warning(self, mock_chmod, mock_mk # 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 - assert mock_chmod.call_count >= 1 + self.assertGreaterEqual(mock_chmod.call_count, 1) first_call = mock_chmod.call_args_list[0] - assert first_call == call(test_temp_dir, 0o700) + 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) From 3eb1cc9c6f27d4099f55fef5c272b1d0c538b308 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 12 Feb 2026 06:57:14 +0200 Subject: [PATCH 13/17] fix: syntax correction in tests --- testv2/testv2_framework/test_flask_framework.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py index c3d70c61..b7dba5cd 100644 --- a/testv2/testv2_framework/test_flask_framework.py +++ b/testv2/testv2_framework/test_flask_framework.py @@ -504,9 +504,9 @@ def test_session_directory_permissions(self, mock_chmod, mock_mkdtemp, mock_sess # 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 - assert mock_chmod.call_count >= 1 + self.assertGreaterEqual(mock_chmod.call_count, 1) first_call = mock_chmod.call_args_list[0] - assert first_call == call(test_temp_dir, 0o700) + self.assertEqual(first_call, call(test_temp_dir, 0o700)) def test_secret_key_length(self): """Test that auto-generated SECRET_KEY has sufficient length.""" From b71ff942290c6325b6e79e249cf945e3012939b8 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 12 Feb 2026 07:35:55 +0200 Subject: [PATCH 14/17] Refactor test_flask_framework.py: reduce duplication and improve code quality - Extract BaseFlaskFrameworkTest base class with common setUp/tearDown logic to eliminate ~100 lines of duplicated code across 4 test classes - Replace try/except blocks with idiomatic shutil.rmtree(ignore_errors=True) - Prefix unused mock parameters with _ to signal intentional non-use and silence linter warnings (18 parameters updated) - Fix framework tracking in test_session_directory_permissions by renaming to _framework (mocks prevent real directory creation) --- kinde_fastapi/examples/example_app.py | 8 +- .../testv2_framework/test_flask_framework.py | 211 ++++++------------ 2 files changed, 69 insertions(+), 150 deletions(-) diff --git a/kinde_fastapi/examples/example_app.py b/kinde_fastapi/examples/example_app.py index 52b6a8aa..8344bc71 100644 --- a/kinde_fastapi/examples/example_app.py +++ b/kinde_fastapi/examples/example_app.py @@ -30,7 +30,7 @@ - /user - Returns user information (redirects to login if not authenticated) """ -from fastapi import FastAPI, Request +from fastapi import FastAPI from fastapi.responses import HTMLResponse, RedirectResponse from .session import InMemorySessionMiddleware import os @@ -64,7 +64,7 @@ # 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. """ @@ -95,7 +95,7 @@ async def home(request: Request): """ except Exception as e: error_msg = html.escape(str(e)) - logger.error(f"Error getting user info: {e}") + logger.exception("Error getting user info") return f""" @@ -134,7 +134,7 @@ async def protected_route(): "user": user.get('email') } except Exception as e: - logger.error(f"Error getting user info in protected route: {e}") + logger.exception("Error getting user info in protected route") return RedirectResponse("/login") diff --git a/testv2/testv2_framework/test_flask_framework.py b/testv2/testv2_framework/test_flask_framework.py index b7dba5cd..b214aa17 100644 --- a/testv2/testv2_framework/test_flask_framework.py +++ b/testv2/testv2_framework/test_flask_framework.py @@ -8,22 +8,14 @@ from kinde_sdk.auth.oauth import OAuth -class TestFlaskFramework(unittest.TestCase): - """Test cases for FlaskFramework configuration.""" +class BaseFlaskFrameworkTest(unittest.TestCase): + """Base test class with common setUp/tearDown logic for all Flask framework tests.""" def setUp(self): - """Set up test fixtures.""" + """Set up test fixtures. Subclasses can override to customize behavior.""" # Store original env vars - snapshot ALL env vars self.original_env = dict(os.environ) - # 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_') - # Track created frameworks for cleanup self.created_frameworks = [] @@ -34,14 +26,17 @@ def tearDown(self): 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): - try: - shutil.rmtree(session_dir) - except Exception: - pass # Ignore cleanup errors + shutil.rmtree(session_dir, ignore_errors=True) - # Clean up test session directory + # 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) + 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()) @@ -54,9 +49,26 @@ def tearDown(self): # Restore original values for key, value in self.original_env.items(): os.environ[key] = value + + +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): + 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: @@ -75,8 +87,9 @@ def test_secret_key_auto_generation_with_warning(self, mock_logger): "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): + 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 @@ -93,7 +106,7 @@ def test_secret_key_from_environment(self, mock_logger): self.assertEqual(len(warning_calls), 0) @patch('kinde_flask.framework.flask_framework.Session') - def test_session_type_default(self, mock_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'] @@ -104,7 +117,7 @@ def test_session_type_default(self, mock_session): self.assertEqual(framework.app.config['SESSION_TYPE'], 'filesystem') @patch('kinde_flask.framework.flask_framework.Session') - def test_session_type_from_environment(self, mock_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' @@ -145,8 +158,9 @@ def test_session_file_dir_auto_generation_with_warning(self, mock_chmod, mock_mk "Set SESSION_FILE_DIR environment variable for production use." ) + @patch('kinde_flask.framework.flask_framework.Session') @patch('kinde_flask.framework.flask_framework.logger') - def test_session_file_dir_from_environment(self, mock_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 @@ -175,7 +189,7 @@ def test_flask_session_initialization(self, mock_logger, mock_session): 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): + def test_session_permanent_is_false(self, _mock_session): """Test that SESSION_PERMANENT is set to False.""" framework = FlaskFramework() self.created_frameworks.append(framework) @@ -183,61 +197,24 @@ def test_session_permanent_is_false(self, mock_session): self.assertFalse(framework.app.config['SESSION_PERMANENT']) -class TestFlaskFrameworkRoutes(unittest.TestCase): +class TestFlaskFrameworkRoutes(BaseFlaskFrameworkTest): """Test cases for Flask framework route handling.""" def setUp(self): """Set up test fixtures.""" - # Store original env vars - snapshot ALL env vars - self.original_env = dict(os.environ) + 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) - - # Track created frameworks for cleanup - self.created_frameworks = [] - - def tearDown(self): - """Clean up after tests.""" - # Get session dir before clearing env - session_dir = os.environ.get('SESSION_FILE_DIR') - - # Clean up any framework-created session directories - for framework in self.created_frameworks: - if hasattr(framework, 'app') and framework.app: - fw_session_dir = framework.app.config.get('SESSION_FILE_DIR') - if fw_session_dir and os.path.exists(fw_session_dir): - try: - shutil.rmtree(fw_session_dir) - except Exception: - pass # Ignore cleanup errors - - # Clean up test session directory from setUp - if session_dir and os.path.exists(session_dir): - try: - shutil.rmtree(session_dir) - except Exception: - pass - - # 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 @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): + 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) @@ -261,7 +238,7 @@ def test_login_route_event_loop_management(self, mock_run_async, mock_session): @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): + 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) @@ -288,7 +265,7 @@ def test_logout_route_event_loop_management(self, mock_run_async, mock_session): @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): + 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) @@ -312,7 +289,7 @@ def test_register_route_event_loop_management(self, mock_run_async, mock_session @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): + 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) @@ -335,56 +312,20 @@ def test_event_loop_closed_on_exception(self, mock_new_event_loop, mock_session) mock_loop.close.assert_called() -class TestFlaskFrameworkInterface(unittest.TestCase): +class TestFlaskFrameworkInterface(BaseFlaskFrameworkTest): """Test cases for FlaskFramework interface compliance.""" def setUp(self): """Set up test fixtures.""" - # Store original env vars - snapshot ALL env vars - self.original_env = dict(os.environ) + 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_') - - # Track created frameworks for cleanup - self.created_frameworks = [] + self._setup_session_dir = True # Flag for cleanup - def tearDown(self): - """Clean up after tests.""" - # Get session dir before clearing env - session_dir = os.environ.get('SESSION_FILE_DIR') - - # Clean up any framework-created session directories - for framework in self.created_frameworks: - if hasattr(framework, 'app') and framework.app: - fw_session_dir = framework.app.config.get('SESSION_FILE_DIR') - if fw_session_dir and os.path.exists(fw_session_dir): - try: - shutil.rmtree(fw_session_dir) - except Exception: - pass # Ignore cleanup errors - - # Clean up test session directory from setUp - if session_dir and os.path.exists(session_dir): - try: - shutil.rmtree(session_dir) - except Exception: - pass - - # 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 - - def test_framework_properties(self): + @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) @@ -393,7 +334,8 @@ def test_framework_properties(self): self.assertIn("Flask", framework.get_description()) self.assertIn("Kinde", framework.get_description()) - def test_get_app_returns_flask_instance(self): + @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) @@ -401,7 +343,8 @@ def test_get_app_returns_flask_instance(self): app = framework.get_app() self.assertIsInstance(app, Flask) - def test_start_and_stop(self): + @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) @@ -418,7 +361,7 @@ def test_start_and_stop(self): self.assertFalse(framework._initialized) @patch('kinde_flask.framework.flask_framework.Session') - def test_can_auto_detect(self, mock_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) @@ -426,7 +369,8 @@ def test_can_auto_detect(self, mock_session): # Flask is installed (we're using it in tests) self.assertTrue(framework.can_auto_detect()) - def test_set_oauth(self): + @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) @@ -436,7 +380,8 @@ def test_set_oauth(self): framework.set_oauth(mock_oauth) self.assertEqual(framework._oauth, mock_oauth) - def test_user_id_management(self): + @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) @@ -452,55 +397,28 @@ def test_user_id_management(self): self.assertEqual(framework.get_user_id(), 'test_user_123') -class TestFlaskFrameworkSecurity(unittest.TestCase): +class TestFlaskFrameworkSecurity(BaseFlaskFrameworkTest): """Test cases for Flask framework security features.""" def setUp(self): """Set up test fixtures.""" - # Store original env vars - snapshot ALL env vars - self.original_env = dict(os.environ) + 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] - - # 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): - try: - shutil.rmtree(session_dir) - except Exception: - pass # Ignore cleanup errors - - # 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 @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): + 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 = FlaskFramework() + # 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 @@ -508,7 +426,8 @@ def test_session_directory_permissions(self, mock_chmod, mock_mkdtemp, mock_sess first_call = mock_chmod.call_args_list[0] self.assertEqual(first_call, call(test_temp_dir, 0o700)) - def test_secret_key_length(self): + @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'] From ae150d8c54bcd54c55d2c889b19da9a76f26e39d Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Thu, 12 Feb 2026 07:43:41 +0200 Subject: [PATCH 15/17] Removed unused variable in except clause --- kinde_fastapi/examples/example_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kinde_fastapi/examples/example_app.py b/kinde_fastapi/examples/example_app.py index 8344bc71..6431bee7 100644 --- a/kinde_fastapi/examples/example_app.py +++ b/kinde_fastapi/examples/example_app.py @@ -133,7 +133,7 @@ async def protected_route(): "message": "This is a protected route", "user": user.get('email') } - except Exception as e: + except Exception: logger.exception("Error getting user info in protected route") return RedirectResponse("/login") From b4779d0c2d58540608264f73b1a971d262fcdb6a Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Fri, 13 Feb 2026 07:09:49 +0200 Subject: [PATCH 16/17] fix: use user: prefix for OAuth state storage keys --- kinde_sdk/auth/oauth.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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, From 960fa75df01632db05bd2807e331a6ec1b1a3343 Mon Sep 17 00:00:00 2001 From: Koman Rudden Date: Fri, 13 Feb 2026 07:15:24 +0200 Subject: [PATCH 17/17] fix: oauth test for null framework fixed --- testv2/testv2_core/test_null_framework.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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")