Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions kinde_fastapi/framework/fastapi_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from fastapi.responses import RedirectResponse, HTMLResponse
from kinde_sdk.core.framework.framework_interface import FrameworkInterface
from kinde_sdk.auth.oauth import OAuth
from kinde_sdk.core.exceptions import KindeLoginException
from ..middleware.framework_middleware import FrameworkMiddleware
import os
import uuid
Expand Down Expand Up @@ -183,15 +184,18 @@ async def callback(request: Request, code: Optional[str] = None, state: Optional
except Exception as ex:
return HTMLResponse(f"Error parsing reauth state: {str(ex)}", status_code=400)

# Reject before handle_redirect, which would consume the pending state
if not code:
return HTMLResponse("Authentication failed: Missing authorization code", status_code=400)

user_id = request.session.get('user_id') or str(uuid.uuid4())

try:
assert self._oauth is not None
await self._oauth.handle_redirect(code, user_id, state)
except Exception as e:
if "State not found" in str(e):
return HTMLResponse("Error: State not found. Please check Kinde Python SDK documentation.\n" + str(e), status_code=500)
raise e
except KindeLoginException as e:
# Missing, forged or stale callbacks are client errors, as in the Flask route
return HTMLResponse(f"Authentication failed: {e}", status_code=400)

request.session['user_id'] = user_id

Expand Down
56 changes: 42 additions & 14 deletions kinde_sdk/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import requests
import logging
import time
import jwt
from typing import Any, Dict, Optional
from urllib.parse import urlencode

Expand Down Expand Up @@ -514,13 +515,30 @@ async def handle_redirect(self, code: str, user_id: str, state: Optional[str] =
Returns:
Dict with user and token information
"""
# Verify state if provided
if state:
stored_state = self._session_manager.storage_manager.get("user:state")
if not stored_state or state != stored_state.get("value"):
self._logger.error("State mismatch in OAuth callback")
raise KindeLoginException("Invalid state parameter")

storage = self._session_manager.storage_manager

# Verify state. A callback is only accepted for a login started by this
# SDK in this session: a state must have been stored, and the callback
# must present the same value. Missing state is rejected, not skipped.
stored_state = storage.get("user:state")
expected_state = stored_state.get("value") if stored_state else None
if not expected_state or state != expected_state:
self._logger.error("State mismatch in OAuth callback")
raise KindeLoginException("Invalid state parameter")
Comment on lines +525 to +527

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right rejection, and it happens before the token request. One caller does not surface it as a client error.

kinde_fastapi/framework/fastapi_framework.py (unchanged, so I can't comment on that line directly) only special-cases "State not found", which nothing in the SDK raises, then re-raises. The new message is Invalid state parameter, so FastAPI returns 500. Flask maps the same KindeLoginException to 400. Missing state is now the normal reject path, so forged and stale callbacks will show up as server errors. Map KindeLoginException to 400 in that route.

That FastAPI route also never requires code before handle_redirect. A request with a valid state and no code still deletes state, nonce, and the code verifier, then calls the token endpoint. Flask rejects a missing code first. The new route tests allow any status >= 400, so they pass on the 500.

@KomanRudden KomanRudden Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FastAPI callback now returns 400 for a missing code before calling handle_redirect, so the state, nonce and code verifier are kept, and returns 400 for KindeLoginException, matching Flask. The unused "State not found" branch is removed. The route tests now assert 400 exactly, and there is a missing-code test for both Flask and FastAPI.


# Every SDK-started login stores a nonce. Without it the ID token can't be
# bound to this login, so reject before the code is redeemed.
stored_nonce = storage.get("user:nonce")
expected_nonce = stored_nonce.get("value") if stored_nonce else None
if not expected_nonce:
self._logger.error("No nonce stored for OAuth callback")
raise KindeLoginException("Missing nonce for this login")

# State and nonce are single-use: consume them once they have been
# validated, so they can't be replayed even if the rest of the flow fails.
storage.delete("user:state")
storage.delete("user:nonce")

# Get code verifier for PKCE
code_verifier = None
stored_code_verifier = self._session_manager.storage_manager.get("user:code_verifier")
Expand All @@ -536,6 +554,23 @@ async def handle_redirect(self, code: str, user_id: str, state: Optional[str] =
except Exception as e:
self._logger.error(f"Token exchange failed: {str(e)}")
raise KindeTokenException(f"Failed to exchange code for tokens: {str(e)}") from e

# Verify the ID token nonce matches the one sent in the authorization request.
# A response without an ID token is rejected too: otherwise a swapped code
# from an authorization request without `openid` would skip this check.
id_token = token_data.get("id_token")
if not id_token:
self._logger.error("Token response has no ID token")
raise KindeLoginException("Missing ID token")
try:
# Signature not verified, as elsewhere in the SDK: the token comes
# directly from the token endpoint over TLS
id_token_claims = jwt.decode(id_token, options={"verify_signature": False})
except jwt.PyJWTError as e:
raise KindeLoginException("Invalid ID token") from e
if id_token_claims.get("nonce") != expected_nonce:
self._logger.error("Nonce mismatch in ID token")
raise KindeLoginException("Invalid nonce in ID token")

# Store tokens
user_info = {
Expand All @@ -562,13 +597,6 @@ async def handle_redirect(self, code: str, user_id: str, state: Optional[str] =
logger=self._logger
)

# Clean up state
if state:
self._session_manager.storage_manager.delete("user:state")

# Clean up nonce
self._session_manager.storage_manager.delete("user:nonce")

return {
"tokens": token_data,
"user": user_details
Expand Down
Loading