fastapi-gsap/gsap_broker/drivers/entra.py
Tyler J King 5015f3dd43 fix(drivers): JWKS verification for Keycloak, remove Entra fallback, gate on_behalf_of
C-1: Keycloak driver now verifies JWT signatures via JWKS.
     Forged tokens are rejected. Previously any base64 JWT was accepted.
C-2: on_behalf_of requires gsap:impersonate role in JWT claims.
C-3: Entra driver denies on JWKS failure (no unverified fallback).
H-10: JWKS cache refreshes on kid miss for key rotation.

Shared JWKSVerifier used by both drivers. alg=none blocked.
iss, aud, exp validated for all tokens.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 07:51:38 -04:00

110 lines
4 KiB
Python

# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Entra identity driver — GSAP §2.2.
Validates Entra-issued JWTs directly via JWKS verification.
Extracts device_id for compliance gating, MFA status, roles,
and constructs DID from Entra tenant + oid.
Fix C-3: JWKS fetch failure results in denial. Never falls back
to unverified claims.
Fix H-10: JWKS cache refreshes on kid miss for key rotation.
"""
import logging
from typing import Optional
from .base import AuthResult, ElevationRequired, IdentityDriver
from .jwks import AuthenticationError, JWKSVerifier
logger = logging.getLogger(__name__)
class EntraDriver(IdentityDriver):
"""Identity driver for direct Entra JWT validation."""
async def authenticate(self) -> AuthResult:
raw_token = self.config.get("_raw_token", "")
tenant_id = self.config.get("entra_tenant_id", "")
expected_audience = self.config.get("entra_client_id", "")
if not raw_token:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason="No token in context.",
)
if not tenant_id:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason="Entra tenant_id not configured.",
)
# Fix C-3: verify via JWKS — no fallback on failure
verifier = JWKSVerifier(
jwks_url=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys",
audience=expected_audience,
issuer=f"https://login.microsoftonline.com/{tenant_id}/v2.0",
)
try:
token_data = await verifier.verify_or_refresh(raw_token)
except AuthenticationError as e:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason=str(e),
)
# Extract claims from VERIFIED token data
oid = token_data.get("oid", "")
tid = token_data.get("tid", tenant_id)
roles = token_data.get("roles", [])
acrs = token_data.get("acrs", [])
amr = token_data.get("amr", [])
device_id = token_data.get("deviceid") or token_data.get("device_id")
upn = token_data.get("preferred_username") or token_data.get("upn", "")
display_name = token_data.get("name", upn)
if not oid:
return AuthResult(
status=AuthResult.STATUS_DENIED,
denial_reason="Token missing oid claim.",
)
# Check role requirement for requested accord
requested_accord = self.config.get("requested_accord", "")
required_role = self.config.get("accord_roles", {}).get(requested_accord, "")
suffix = self.config.get("elevated_suffix", "-elevated")
elevation_active = [r for r in roles if r.endswith(suffix)]
if required_role and required_role not in roles:
return AuthResult(
status=AuthResult.STATUS_PENDING_ELEVATION,
elevation_required=ElevationRequired(
role=required_role,
activation_url="/governance/elevate/",
instructions=f"Request elevation to '{required_role}' via POST /governance/elevate/",
mechanism="entra_pim",
),
)
# Construct DID
domain = self.config.get("domain", "guildhouse.dev")
principal_did = f"did:web:{domain}:principal:{oid}"
# MFA detection
mfa_satisfied = "mfa" in amr or "ngcmfa" in amr
return AuthResult(
status=AuthResult.STATUS_AUTHORIZED,
principal_did=principal_did,
display_name=display_name,
stable_id=oid,
token_jti=token_data.get("jti", ""),
elevation_active=elevation_active,
mfa_satisfied=mfa_satisfied,
device_id=device_id,
)
async def revoke(self, session_id: str) -> None:
logger.info("Entra revoke: %s", session_id)