fix: OIDC audience validation - use string not list
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m15s

python-jose jwt.decode() requires audience as string or None, not list.
Now extract and validate audience from unverified claims first,
then use token's actual audience for JWT decode.

Fixes "audience must be a string or None" error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-01-08 15:35:05 +01:00
co-authored by Claude Opus 4.5
parent ce761a9d2c
commit 3bb3b01dbd
4 changed files with 30 additions and 10 deletions
+9
View File
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.10.8] - 2026-01-08
### Fixed
- **OIDC audience validation** - python-jose requires string audience, not list
- Extract and validate audience from unverified claims first
- Use token's actual audience for JWT decode (after validating it's allowed)
- Fixes "audience must be a string or None" error
## [1.10.7] - 2026-01-08 ## [1.10.7] - 2026-01-08
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "core-api" name = "core-api"
version = "1.10.7" version = "1.10.8"
description = "Core Code API - Infrastructure management and tools API" description = "Core Code API - Infrastructure management and tools API"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+11 -6
View File
@@ -131,15 +131,21 @@ async def get_current_user(
token = credentials.credentials token = credentials.credentials
try: try:
# First, extract issuer from unverified claims to know which JWKS to use # First, extract issuer and audience from unverified claims
unverified_claims = jwt.get_unverified_claims(token) unverified_claims = jwt.get_unverified_claims(token)
token_issuer = unverified_claims.get("iss", "") token_issuer = unverified_claims.get("iss", "")
token_audience = unverified_claims.get("aud", "")
# Validate issuer is in our allowed list # Validate issuer is in our allowed list
if not oidc_config.is_valid_issuer(token_issuer): if not oidc_config.is_valid_issuer(token_issuer):
logger.warning(f"Invalid token issuer: {token_issuer}") logger.warning(f"Invalid token issuer: {token_issuer}")
raise HTTPException(status_code=401, detail="Invalid token issuer") raise HTTPException(status_code=401, detail="Invalid token issuer")
# Validate audience is in our allowed list
if token_audience not in oidc_config.audiences:
logger.warning(f"Invalid token audience: {token_audience}")
raise HTTPException(status_code=401, detail="Invalid token audience")
# Decode token header to get key ID # Decode token header to get key ID
unverified_header = jwt.get_unverified_header(token) unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid") kid = unverified_header.get("kid")
@@ -160,18 +166,17 @@ async def get_current_user(
logger.warning(f"No matching key found for kid: {kid}") logger.warning(f"No matching key found for kid: {kid}")
raise HTTPException(status_code=401, detail="Invalid token key") raise HTTPException(status_code=401, detail="Invalid token key")
# Verify and decode token (accepts any of the configured audiences) # Verify and decode token using the token's actual issuer and audience
# Use the token's issuer for validation (already verified it's in our allowed list)
payload = jwt.decode( payload = jwt.decode(
token, token,
rsa_key, rsa_key,
algorithms=["RS256"], algorithms=["RS256"],
audience=oidc_config.audiences, audience=token_audience, # Use the token's audience (already validated)
issuer=token_issuer, issuer=token_issuer, # Use the token's issuer (already validated)
) )
user_email = payload.get("email", "unknown") user_email = payload.get("email", "unknown")
logger.info(f"Authenticated user: {user_email}") logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
return payload return payload
+9 -3
View File
@@ -185,6 +185,7 @@ async def get_current_user(
kid = unverified_header.get("kid") kid = unverified_header.get("kid")
token_issuer = unverified_claims.get("iss", "") token_issuer = unverified_claims.get("iss", "")
token_audience = unverified_claims.get("aud", "")
if not kid: if not kid:
raise HTTPException(status_code=401, detail="Invalid token format") raise HTTPException(status_code=401, detail="Invalid token format")
@@ -195,6 +196,11 @@ async def get_current_user(
logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})") logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})")
raise HTTPException(status_code=401, detail="Invalid token issuer") raise HTTPException(status_code=401, detail="Invalid token issuer")
# Validate audience is in allowed list
if token_audience not in oidc_config.audiences:
logger.warning(f"Invalid token audience: {token_audience} (allowed: {oidc_config.audiences})")
raise HTTPException(status_code=401, detail="Invalid token audience")
# Get JWKS for this specific issuer # Get JWKS for this specific issuer
jwks = get_jwks_for_issuer(token_issuer) jwks = get_jwks_for_issuer(token_issuer)
rsa_key = None rsa_key = None
@@ -208,13 +214,13 @@ async def get_current_user(
logger.warning(f"No matching key found for kid: {kid}") logger.warning(f"No matching key found for kid: {kid}")
raise HTTPException(status_code=401, detail="Invalid token key") raise HTTPException(status_code=401, detail="Invalid token key")
# Verify and decode token using the token's actual issuer # Verify and decode token using the token's actual issuer and audience
payload = jwt.decode( payload = jwt.decode(
token, token,
rsa_key, rsa_key,
algorithms=["RS256"], algorithms=["RS256"],
audience=oidc_config.audiences, audience=token_audience, # Use the token's audience (already validated)
issuer=token_issuer, # Use the token's issuer for validation issuer=token_issuer, # Use the token's issuer (already validated)
) )
user_email = payload.get("email", "unknown") user_email = payload.get("email", "unknown")