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")