From 3bb3b01dbd28452776818a9a67a2ee6a0a4e53fd Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 8 Jan 2026 15:35:05 +0100 Subject: [PATCH] fix: OIDC audience validation - use string not list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- src/auth/oidc.py | 17 +++++++++++------ src/domains/auth/oidc.py | 12 +++++++++--- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a214b1f..31ab51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/pyproject.toml b/pyproject.toml index c8f526d..15fb408 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "core-api" -version = "1.10.7" +version = "1.10.8" description = "Core Code API - Infrastructure management and tools API" readme = "README.md" requires-python = ">=3.12" diff --git a/src/auth/oidc.py b/src/auth/oidc.py index 32179b3..df22f53 100644 --- a/src/auth/oidc.py +++ b/src/auth/oidc.py @@ -131,15 +131,21 @@ async def get_current_user( token = credentials.credentials 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) token_issuer = unverified_claims.get("iss", "") + token_audience = unverified_claims.get("aud", "") # Validate issuer is in our allowed list if not oidc_config.is_valid_issuer(token_issuer): logger.warning(f"Invalid token issuer: {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 unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") @@ -160,18 +166,17 @@ async def get_current_user( logger.warning(f"No matching key found for kid: {kid}") raise HTTPException(status_code=401, detail="Invalid token key") - # Verify and decode token (accepts any of the configured audiences) - # Use the token's issuer for validation (already verified it's in our allowed list) + # Verify and decode token using the token's actual issuer and audience payload = jwt.decode( token, rsa_key, algorithms=["RS256"], - audience=oidc_config.audiences, - issuer=token_issuer, + audience=token_audience, # Use the token's audience (already validated) + issuer=token_issuer, # Use the token's issuer (already validated) ) 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 diff --git a/src/domains/auth/oidc.py b/src/domains/auth/oidc.py index 74e1708..a1d34bf 100644 --- a/src/domains/auth/oidc.py +++ b/src/domains/auth/oidc.py @@ -185,6 +185,7 @@ async def get_current_user( kid = unverified_header.get("kid") token_issuer = unverified_claims.get("iss", "") + token_audience = unverified_claims.get("aud", "") if not kid: 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})") 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 jwks = get_jwks_for_issuer(token_issuer) rsa_key = None @@ -208,13 +214,13 @@ async def get_current_user( logger.warning(f"No matching key found for kid: {kid}") 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( token, rsa_key, algorithms=["RS256"], - audience=oidc_config.audiences, - issuer=token_issuer, # Use the token's issuer for validation + audience=token_audience, # Use the token's audience (already validated) + issuer=token_issuer, # Use the token's issuer (already validated) ) user_email = payload.get("email", "unknown")