Enhance OAuth security, PKCE, and state validation

- Enforced stricter PKCE requirements by rejecting insecure 'plain' method for public clients.
- Transitioned client secret hashing to bcrypt for improved security and migration compatibility.
- Added constant-time comparison for state parameter validation to prevent timing attacks.
- Improved error handling and logging for OAuth workflows, including malformed headers and invalid scopes.
- Upgraded Google OIDC token validation to verify both signature and nonce.
- Refactored OAuth service methods and schemas for better readability, consistency, and compliance with RFC specifications.
This commit is contained in:
Felipe Cardoso
2025-11-26 00:14:26 +01:00
parent 0ea428b718
commit dc875c5c95
6 changed files with 284 additions and 159 deletions

View File

@@ -282,35 +282,16 @@ class OAuthService:
**token_params,
)
# SECURITY: Validate nonce in ID token for OpenID Connect (Google)
# This prevents token replay attacks (OpenID Connect Core 3.1.3.7)
# SECURITY: Validate ID token signature and nonce for OpenID Connect
# This prevents token forgery and replay attacks (OIDC Core 3.1.3.7)
if provider == "google" and state_record.nonce:
id_token = token.get("id_token")
if id_token:
import base64
import json
# Decode ID token payload (JWT format: header.payload.signature)
try:
payload_b64 = id_token.split(".")[1]
# Add padding if needed
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload_json = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_json)
token_nonce = payload.get("nonce")
if token_nonce != state_record.nonce:
logger.warning(
f"OAuth nonce mismatch: expected {state_record.nonce}, "
f"got {token_nonce}"
)
raise AuthenticationError("Invalid ID token nonce")
except (IndexError, ValueError, json.JSONDecodeError) as e:
logger.warning(f"Failed to decode ID token for nonce validation: {e}")
# Continue without nonce validation if ID token is malformed
# The token will still be validated when getting user info
await OAuthService._verify_google_id_token(
id_token=str(id_token),
expected_nonce=str(state_record.nonce),
client_id=client_id,
)
except AuthenticationError:
raise
except Exception as e:
@@ -337,7 +318,9 @@ class OAuthService:
# Email can be None if user didn't grant email permission
# SECURITY: Normalize email (lowercase, strip) to prevent case-based account duplication
email_raw = user_info.get("email")
provider_email: str | None = str(email_raw).lower().strip() if email_raw else None
provider_email: str | None = (
str(email_raw).lower().strip() if email_raw else None
)
if not provider_user_id:
raise AuthenticationError("Provider did not return user ID")
@@ -521,6 +504,106 @@ class OAuthService:
return user_info
# Google's OIDC configuration endpoints
GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
GOOGLE_ISSUERS = ("https://accounts.google.com", "accounts.google.com")
@staticmethod
async def _verify_google_id_token(
id_token: str,
expected_nonce: str,
client_id: str,
) -> dict[str, object]:
"""
Verify Google ID token signature and claims.
SECURITY: This properly verifies the ID token by:
1. Fetching Google's public keys (JWKS)
2. Verifying the JWT signature against the public key
3. Validating issuer, audience, expiry, and nonce claims
Args:
id_token: The ID token JWT string
expected_nonce: The nonce we sent in the authorization request
client_id: Our OAuth client ID (expected audience)
Returns:
Decoded ID token payload
Raises:
AuthenticationError: If verification fails
"""
import httpx
from jose import jwt as jose_jwt
from jose.exceptions import JWTError
try:
# Fetch Google's public keys (JWKS)
# In production, consider caching this with TTL matching Cache-Control header
async with httpx.AsyncClient() as client:
jwks_response = await client.get(
OAuthService.GOOGLE_JWKS_URL,
timeout=10.0,
)
jwks_response.raise_for_status()
jwks = jwks_response.json()
# Get the key ID from the token header
unverified_header = jose_jwt.get_unverified_header(id_token)
kid = unverified_header.get("kid")
if not kid:
raise AuthenticationError("ID token missing key ID (kid)")
# Find the matching public key
public_key = None
for key in jwks.get("keys", []):
if key.get("kid") == kid:
public_key = key
break
if not public_key:
raise AuthenticationError("ID token signed with unknown key")
# Verify the token signature and decode claims
# jose library will verify signature against the JWK
payload = jose_jwt.decode(
id_token,
public_key,
algorithms=["RS256"], # Google uses RS256
audience=client_id,
issuer=OAuthService.GOOGLE_ISSUERS,
options={
"verify_signature": True,
"verify_aud": True,
"verify_iss": True,
"verify_exp": True,
"verify_iat": True,
},
)
# Verify nonce (OIDC replay attack protection)
token_nonce = payload.get("nonce")
if token_nonce != expected_nonce:
logger.warning(
f"OAuth ID token nonce mismatch: expected {expected_nonce}, "
f"got {token_nonce}"
)
raise AuthenticationError("Invalid ID token nonce")
logger.debug("Google ID token verified successfully")
return payload
except JWTError as e:
logger.warning(f"Google ID token verification failed: {e}")
raise AuthenticationError("Invalid ID token signature")
except httpx.HTTPError as e:
logger.error(f"Failed to fetch Google JWKS: {e}")
# If we can't verify the ID token, fail closed for security
raise AuthenticationError("Failed to verify ID token")
except Exception as e:
logger.error(f"Unexpected error verifying Google ID token: {e}")
raise AuthenticationError("ID token verification error")
@staticmethod
async def _create_oauth_user(
db: AsyncSession,