fix: verify JWT signature via JWKS and fix auth dev bypass condition
- msal_auth.py: replace verify_signature=False with real JWKS verification using PyJWKClient; validates RS256 signature, aud=clientId, issuer v2.0 - App.tsx: split DEV bypass from empty-accounts case — in production, accounts.length === 0 now correctly triggers loginRedirect instead of calling fetchMe without a token Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dc1add0b1b
commit
08710e1a16
2 changed files with 36 additions and 15 deletions
|
|
@ -1,18 +1,33 @@
|
|||
"""
|
||||
MSAL / Azure AD token validator (SPA PKCE flow).
|
||||
Backend only validates incoming Bearer JWTs — no server-side MSAL client needed.
|
||||
Frontend sends the MSAL idToken (aud = clientId) for user identification.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from ..config_runtime import server_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# JWKS client caches keys after first fetch
|
||||
_jwks_client: Optional[PyJWKClient] = None
|
||||
|
||||
|
||||
def _get_jwks_client() -> PyJWKClient:
|
||||
global _jwks_client
|
||||
if _jwks_client is None:
|
||||
jwks_uri = (
|
||||
f"https://login.microsoftonline.com/"
|
||||
f"{server_config.MSAL_TENANT_ID}/discovery/v2.0/keys"
|
||||
)
|
||||
_jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
||||
return _jwks_client
|
||||
|
||||
|
||||
class MSALAuthenticator:
|
||||
def __init__(self):
|
||||
|
|
@ -31,29 +46,30 @@ class MSALAuthenticator:
|
|||
return None
|
||||
|
||||
try:
|
||||
# Decode without signature verification (PKCE SPA tokens may use
|
||||
# audience = client_id; full sig verification requires fetching JWKS).
|
||||
unverified = jwt.decode(
|
||||
jwks_client = _get_jwks_client()
|
||||
signing_key = jwks_client.get_signing_key_from_jwt(access_token)
|
||||
claims = jwt.decode(
|
||||
access_token,
|
||||
options={"verify_signature": False, "verify_aud": False},
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
audience=server_config.MSAL_CLIENT_ID,
|
||||
issuer=f"https://login.microsoftonline.com/{server_config.MSAL_TENANT_ID}/v2.0",
|
||||
)
|
||||
|
||||
user_id = unverified.get('oid')
|
||||
user_id = claims.get('oid')
|
||||
if not user_id:
|
||||
logger.warning("Token missing 'oid' claim")
|
||||
return None
|
||||
|
||||
exp = unverified.get('exp', 0)
|
||||
if exp < time.time():
|
||||
logger.warning("Token expired")
|
||||
return None
|
||||
|
||||
return {
|
||||
'oid': user_id,
|
||||
'preferred_username': unverified.get('preferred_username') or unverified.get('upn', ''),
|
||||
'name': unverified.get('name', ''),
|
||||
'preferred_username': claims.get('preferred_username') or claims.get('upn', ''),
|
||||
'name': claims.get('name', ''),
|
||||
}
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Token expired")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid JWT: {e}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -22,11 +22,16 @@ function AuthGate({ children }: { children: React.ReactNode }) {
|
|||
if (inProgress !== InteractionStatus.None) return
|
||||
|
||||
const acquire = async () => {
|
||||
// Dev mode: skip MSAL, just call /auth/me directly
|
||||
if (import.meta.env.DEV || accounts.length === 0) {
|
||||
// Dev mode: skip MSAL, just call /auth/me directly (backend uses DEV_MODE)
|
||||
if (import.meta.env.DEV) {
|
||||
if (!user) fetchMe()
|
||||
return
|
||||
}
|
||||
// Not yet authenticated — redirect to Azure AD login
|
||||
if (accounts.length === 0) {
|
||||
instance.loginRedirect({ scopes: ['openid', 'profile', 'email'] })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await instance.acquireTokenSilent({
|
||||
account: accounts[0],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue