125 lines
4.0 KiB
Python
125 lines
4.0 KiB
Python
|
|
"""Entra ID access-token validation (python-jose + tenant JWKS)."""
|
||
|
|
import logging
|
||
|
|
import time
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
from jose import JWTError, jwt
|
||
|
|
|
||
|
|
from app.config import get_settings
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class AuthError(Exception):
|
||
|
|
def __init__(self, message: str, status_code: int = 401):
|
||
|
|
self.message = message
|
||
|
|
self.status_code = status_code
|
||
|
|
super().__init__(message)
|
||
|
|
|
||
|
|
|
||
|
|
_jwks: dict[str, dict] = {}
|
||
|
|
_jwks_fetched_at: float = 0.0
|
||
|
|
_JWKS_TTL = 60 * 60 * 12
|
||
|
|
|
||
|
|
|
||
|
|
async def _fetch_jwks() -> None:
|
||
|
|
global _jwks, _jwks_fetched_at
|
||
|
|
settings = get_settings()
|
||
|
|
url = (
|
||
|
|
f"https://login.microsoftonline.com/{settings.entra_tenant_id}"
|
||
|
|
"/discovery/v2.0/keys"
|
||
|
|
)
|
||
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
||
|
|
resp = await client.get(url)
|
||
|
|
resp.raise_for_status()
|
||
|
|
_jwks = {k["kid"]: k for k in resp.json().get("keys", [])}
|
||
|
|
_jwks_fetched_at = time.time()
|
||
|
|
logger.info("Fetched %d Entra signing keys", len(_jwks))
|
||
|
|
|
||
|
|
|
||
|
|
async def _get_signing_key(kid: str) -> dict:
|
||
|
|
stale = time.time() - _jwks_fetched_at > _JWKS_TTL
|
||
|
|
if kid not in _jwks or stale:
|
||
|
|
await _fetch_jwks()
|
||
|
|
key = _jwks.get(kid)
|
||
|
|
if key is None:
|
||
|
|
raise AuthError("Token signed with an unknown key.")
|
||
|
|
return key
|
||
|
|
|
||
|
|
|
||
|
|
async def validate_access_token(token: str) -> dict:
|
||
|
|
"""Validate signature, expiry, audience, and issuer; return claims."""
|
||
|
|
settings = get_settings()
|
||
|
|
if not settings.entra_tenant_id or not settings.entra_client_id:
|
||
|
|
raise AuthError(
|
||
|
|
"Entra ID is not configured (ENTRA_TENANT_ID / ENTRA_CLIENT_ID).", 503
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
header = jwt.get_unverified_header(token)
|
||
|
|
key = await _get_signing_key(header.get("kid", ""))
|
||
|
|
claims = jwt.decode(
|
||
|
|
token,
|
||
|
|
key,
|
||
|
|
algorithms=["RS256"],
|
||
|
|
options={"verify_aud": False}, # audience list checked below
|
||
|
|
)
|
||
|
|
except AuthError:
|
||
|
|
raise
|
||
|
|
except JWTError as exc:
|
||
|
|
raise AuthError(f"Invalid token: {exc}") from exc
|
||
|
|
|
||
|
|
aud = claims.get("aud")
|
||
|
|
if aud not in settings.api_audiences:
|
||
|
|
raise AuthError("Token audience does not match this API.")
|
||
|
|
|
||
|
|
tid = settings.entra_tenant_id
|
||
|
|
valid_issuers = {
|
||
|
|
f"https://login.microsoftonline.com/{tid}/v2.0",
|
||
|
|
f"https://sts.windows.net/{tid}/",
|
||
|
|
}
|
||
|
|
if claims.get("iss") not in valid_issuers:
|
||
|
|
raise AuthError("Token issuer does not match the configured tenant.")
|
||
|
|
return claims
|
||
|
|
|
||
|
|
|
||
|
|
async def ensure_group_membership(claims: dict, token: str) -> None:
|
||
|
|
"""Front-door gate: require membership in ENTRA_ALLOWED_GROUP_ID.
|
||
|
|
|
||
|
|
Uses the `groups` claim when present; on claim overage falls back to a
|
||
|
|
delegated Graph checkMemberGroups call.
|
||
|
|
"""
|
||
|
|
settings = get_settings()
|
||
|
|
group_id = settings.entra_allowed_group_id
|
||
|
|
if not group_id:
|
||
|
|
return # gate disabled by configuration
|
||
|
|
|
||
|
|
groups = claims.get("groups")
|
||
|
|
if groups is not None:
|
||
|
|
if group_id in groups:
|
||
|
|
return
|
||
|
|
raise AuthError(
|
||
|
|
"Your account is not a member of the NCR access group.", 403
|
||
|
|
)
|
||
|
|
|
||
|
|
claim_names = claims.get("_claim_names") or {}
|
||
|
|
if "groups" in claim_names:
|
||
|
|
# Group overage: too many groups to embed in the token.
|
||
|
|
from app.services.graph import check_member_group
|
||
|
|
|
||
|
|
try:
|
||
|
|
if await check_member_group(token, group_id):
|
||
|
|
return
|
||
|
|
except Exception as exc:
|
||
|
|
logger.warning("Group overage Graph check failed: %s", exc)
|
||
|
|
raise AuthError(
|
||
|
|
"Could not verify group membership (group overage). Ensure the "
|
||
|
|
"app has delegated GroupMember.Read.All consent, or scope the "
|
||
|
|
"group claim to 'Groups assigned to the application'.", 403
|
||
|
|
) from exc
|
||
|
|
raise AuthError("Your account is not a member of the NCR access group.", 403)
|
||
|
|
|
||
|
|
raise AuthError(
|
||
|
|
"Access token has no groups claim. Add the groups claim in the app "
|
||
|
|
"registration (Token configuration → Add groups claim).", 403
|
||
|
|
)
|