Implement Microsoft MSAL SSO with PKCE flow
Frontend: - Add @azure/msal-browser and @azure/msal-react packages - Create authConfig.ts with MSAL configuration for PKCE flow - Create authService.ts for token acquisition and user info - Wrap App with MsalProvider in index.tsx - Replace dummy login with real MSAL loginPopup() in Login.tsx - Update App.tsx to use useIsAuthenticated/useMsal hooks - Update Profile.tsx to display real user data from claims - Update geminiService.ts to include access_token in WebSocket messages - Update WIPReviewer.tsx to pass msalInstance for auth Backend: - Add python-jose and httpx dependencies for JWT verification - Create auth_service.py with Azure AD JWKS fetching and token verification - Create auth.py FastAPI dependency for protected REST endpoints - Update main.py to verify tokens on WebSocket and protect /info endpoint - Add AZURE_TENANT_ID, AZURE_CLIENT_ID, DISABLE_AUTH to config 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3df1b9fb92
commit
321a9ca820
17 changed files with 538 additions and 60 deletions
|
|
@ -13,3 +13,11 @@ CORS_ORIGINS=http://localhost:3000
|
|||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
|
||||
# Azure AD Configuration (for Microsoft SSO token verification)
|
||||
# Get these from your Azure AD app registration
|
||||
AZURE_TENANT_ID=your_azure_tenant_id_here
|
||||
AZURE_CLIENT_ID=your_azure_client_id_here
|
||||
|
||||
# Development only - set to "true" to disable authentication (NOT for production)
|
||||
DISABLE_AUTH=false
|
||||
|
|
|
|||
|
|
@ -18,10 +18,23 @@ class Settings:
|
|||
_default_ref_docs = Path(__file__).parent.parent.parent / "reference_docs"
|
||||
REFERENCE_DOCS_PATH: str = os.getenv("REFERENCE_DOCS_PATH", str(_default_ref_docs))
|
||||
|
||||
# Azure AD Configuration for token verification
|
||||
AZURE_TENANT_ID: str = os.getenv("AZURE_TENANT_ID", "")
|
||||
AZURE_CLIENT_ID: str = os.getenv("AZURE_CLIENT_ID", "")
|
||||
|
||||
# Auth bypass for development (set to "true" to skip auth)
|
||||
DISABLE_AUTH: bool = os.getenv("DISABLE_AUTH", "false").lower() == "true"
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate required settings are present."""
|
||||
if not self.GEMINI_API_KEY:
|
||||
raise ValueError("GEMINI_API_KEY environment variable is required")
|
||||
|
||||
if not self.DISABLE_AUTH:
|
||||
if not self.AZURE_TENANT_ID:
|
||||
raise ValueError("AZURE_TENANT_ID environment variable is required (or set DISABLE_AUTH=true)")
|
||||
if not self.AZURE_CLIENT_ID:
|
||||
raise ValueError("AZURE_CLIENT_ID environment variable is required (or set DISABLE_AUTH=true)")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
0
backend/app/dependencies/__init__.py
Normal file
0
backend/app/dependencies/__init__.py
Normal file
56
backend/app/dependencies/auth.py
Normal file
56
backend/app/dependencies/auth.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""
|
||||
FastAPI authentication dependencies.
|
||||
|
||||
Provides dependency functions for securing REST endpoints with Azure AD token verification.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.services.auth_service import verify_access_token
|
||||
|
||||
|
||||
async def get_current_user(authorization: Optional[str] = Header(None)) -> dict:
|
||||
"""
|
||||
FastAPI dependency to verify the access token and return user claims.
|
||||
|
||||
Use as a dependency on protected endpoints:
|
||||
@app.get("/protected")
|
||||
async def protected_route(user: dict = Depends(get_current_user)):
|
||||
return {"message": f"Hello {user.get('name')}"}
|
||||
|
||||
Args:
|
||||
authorization: The Authorization header value (Bearer <token>)
|
||||
|
||||
Returns:
|
||||
The token claims dict containing user information
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if token is missing or invalid
|
||||
"""
|
||||
if not authorization:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing authorization header",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Extract token from "Bearer <token>" format
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authorization header format. Expected: Bearer <token>",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = parts[1]
|
||||
claims = await verify_access_token(token)
|
||||
|
||||
if not claims:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return claims
|
||||
|
|
@ -2,10 +2,12 @@ import logging
|
|||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.services.auth_service import verify_access_token
|
||||
from app.dependencies.auth import get_current_user
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
|
|
@ -86,17 +88,18 @@ async def health_check():
|
|||
|
||||
|
||||
@app.get("/info")
|
||||
async def info():
|
||||
"""Get backend information."""
|
||||
async def info(user: dict = Depends(get_current_user)):
|
||||
"""Get backend information. Requires authentication."""
|
||||
if analysis_service:
|
||||
ref_docs = analysis_service.reference_docs
|
||||
doc_summary = ref_docs.get_context_summary()
|
||||
return {
|
||||
"status": "ready",
|
||||
"user": user.get("name", "Unknown"),
|
||||
"agents": ["Legal Agent", "Brand Agent", "Tone Agent", "Channel Agent"],
|
||||
"reference_docs": doc_summary,
|
||||
}
|
||||
return {"status": "initializing"}
|
||||
return {"status": "initializing", "user": user.get("name", "Unknown")}
|
||||
|
||||
|
||||
@app.websocket("/ws/analyze")
|
||||
|
|
@ -105,7 +108,8 @@ async def websocket_analyze(websocket: WebSocket):
|
|||
WebSocket endpoint for proof analysis with real-time updates.
|
||||
|
||||
Protocol:
|
||||
- Client sends: {"type": "analyze", "file_data": "<base64>", "file_type": "image/png", "is_wip": false}
|
||||
- Client sends: {"type": "analyze", "file_data": "<base64>", "file_type": "image/png", "is_wip": false, "access_token": "<jwt>"}
|
||||
- Server verifies token before processing
|
||||
- Server sends: {"type": "agent_started", "agent_name": "..."}
|
||||
- Server sends: {"type": "agent_completed", "agent_name": "...", "review": {...}}
|
||||
- Server sends: {"type": "complete", "result": {...}}
|
||||
|
|
@ -122,6 +126,20 @@ async def websocket_analyze(websocket: WebSocket):
|
|||
logger.info(f"[MAIN] Received message from client {client_id} - type: {data.get('type')}")
|
||||
|
||||
if data.get("type") == "analyze":
|
||||
# Verify access token from message
|
||||
access_token = data.get("access_token")
|
||||
user_claims = await verify_access_token(access_token)
|
||||
|
||||
if not user_claims:
|
||||
logger.warning(f"[MAIN] Authentication failed for client {client_id}")
|
||||
await manager.send_message(client_id, {
|
||||
"type": "error",
|
||||
"message": "Authentication failed. Please sign in again."
|
||||
})
|
||||
continue
|
||||
|
||||
logger.info(f"[MAIN] Authenticated user: {user_claims.get('name', 'unknown')}")
|
||||
|
||||
if analysis_service is None:
|
||||
logger.error("[MAIN] Analysis service not ready")
|
||||
await manager.send_message(client_id, {
|
||||
|
|
|
|||
118
backend/app/services/auth_service.py
Normal file
118
backend/app/services/auth_service.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""
|
||||
Azure AD token verification service.
|
||||
|
||||
Validates JWT access tokens from the frontend using Azure AD's public keys (JWKS).
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from jose import jwt, JWTError
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache for JWKS (JSON Web Key Set)
|
||||
_jwks_cache: dict = {}
|
||||
_jwks_cache_expiry: datetime = datetime.min
|
||||
|
||||
|
||||
async def get_azure_jwks() -> dict:
|
||||
"""
|
||||
Fetch and cache Azure AD's public keys for token verification.
|
||||
Keys are cached for 24 hours to minimize network calls.
|
||||
"""
|
||||
global _jwks_cache, _jwks_cache_expiry
|
||||
|
||||
if datetime.utcnow() < _jwks_cache_expiry and _jwks_cache:
|
||||
return _jwks_cache
|
||||
|
||||
jwks_url = f"https://login.microsoftonline.com/{settings.AZURE_TENANT_ID}/discovery/v2.0/keys"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(jwks_url, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
_jwks_cache = response.json()
|
||||
_jwks_cache_expiry = datetime.utcnow() + timedelta(hours=24)
|
||||
logger.info("Successfully fetched Azure AD JWKS")
|
||||
return _jwks_cache
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch JWKS: {e}")
|
||||
if _jwks_cache: # Return stale cache if available
|
||||
return _jwks_cache
|
||||
raise
|
||||
|
||||
|
||||
async def verify_access_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Verify an Azure AD access token and return the claims.
|
||||
|
||||
Args:
|
||||
token: The JWT access token from the frontend
|
||||
|
||||
Returns:
|
||||
The token claims dict if valid, None if invalid
|
||||
"""
|
||||
if settings.DISABLE_AUTH:
|
||||
logger.warning("Auth disabled - skipping token verification")
|
||||
return {"sub": "dev-user", "name": "Development User", "preferred_username": "dev@localhost"}
|
||||
|
||||
if not token:
|
||||
logger.warning("No token provided")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get Azure AD public keys
|
||||
jwks = await get_azure_jwks()
|
||||
|
||||
# Decode without verification first to get the key ID
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
|
||||
if not kid:
|
||||
logger.warning("No key ID in token header")
|
||||
return None
|
||||
|
||||
# Find the matching key
|
||||
rsa_key = None
|
||||
for key in jwks.get("keys", []):
|
||||
if key.get("kid") == kid:
|
||||
rsa_key = key
|
||||
break
|
||||
|
||||
if not rsa_key:
|
||||
logger.warning(f"Key ID {kid} not found in JWKS, refreshing cache")
|
||||
# Try refreshing JWKS in case keys rotated
|
||||
global _jwks_cache_expiry
|
||||
_jwks_cache_expiry = datetime.min
|
||||
jwks = await get_azure_jwks()
|
||||
for key in jwks.get("keys", []):
|
||||
if key.get("kid") == kid:
|
||||
rsa_key = key
|
||||
break
|
||||
|
||||
if not rsa_key:
|
||||
logger.error("Could not find matching key after refresh")
|
||||
return None
|
||||
|
||||
# Verify and decode the token
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=f"api://{settings.AZURE_CLIENT_ID}",
|
||||
issuer=f"https://sts.windows.net/{settings.AZURE_TENANT_ID}/",
|
||||
)
|
||||
|
||||
logger.info(f"Token verified for user: {claims.get('name', 'unknown')}")
|
||||
return claims
|
||||
|
||||
except JWTError as e:
|
||||
logger.warning(f"JWT verification failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification error: {e}")
|
||||
return None
|
||||
|
|
@ -6,3 +6,5 @@ pydantic>=2.5.0
|
|||
python-multipart>=0.0.9
|
||||
aiofiles>=23.2.1
|
||||
websockets>=12.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
httpx>=0.26.0
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useIsAuthenticated, useMsal } from '@azure/msal-react';
|
||||
import { InteractionStatus } from '@azure/msal-browser';
|
||||
import { Hero } from './components/Hero';
|
||||
import { analyzeProof } from './services/geminiService';
|
||||
import type { AgentReview, AgentName, FlaggedItem, ResolvedItem, ErrorItem } from './types';
|
||||
|
|
@ -24,7 +26,10 @@ export interface DropdownOptions {
|
|||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
// MSAL authentication state
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { instance: msalInstance, inProgress } = useMsal();
|
||||
|
||||
const [currentView, setCurrentView] = useState<View>('Home');
|
||||
const [selectedCampaign, setSelectedCampaign] = useState<string | null>(null);
|
||||
const [selectedProof, setSelectedProof] = useState<any | null>(null);
|
||||
|
|
@ -287,7 +292,7 @@ const App: React.FC = () => {
|
|||
};
|
||||
|
||||
try {
|
||||
const feedback = await analyzeProof(file, handleAgentUpdate);
|
||||
const feedback = await analyzeProof(file, handleAgentUpdate, msalInstance);
|
||||
const previewUrl = await fileToDataUrl(file);
|
||||
|
||||
if (feedback.overallStatus === 'Analysis Error') {
|
||||
|
|
@ -435,7 +440,7 @@ const App: React.FC = () => {
|
|||
};
|
||||
|
||||
try {
|
||||
const feedback = await analyzeProof(file, handleAgentUpdateForRetry);
|
||||
const feedback = await analyzeProof(file, handleAgentUpdateForRetry, msalInstance);
|
||||
const previewUrl = await fileToDataUrl(file);
|
||||
const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`;
|
||||
|
||||
|
|
@ -723,12 +728,14 @@ const App: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsLoggedIn(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setIsLoggedIn(false);
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await msalInstance.logoutPopup({
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
|
|
@ -736,7 +743,7 @@ const App: React.FC = () => {
|
|||
case 'Analytics':
|
||||
return <Analytics />;
|
||||
case 'Profile':
|
||||
return <Profile onLogout={handleLogout} />;
|
||||
return <Profile onLogout={handleLogout} msalInstance={msalInstance} />;
|
||||
case 'CopyGenAI':
|
||||
return <CopyGenAI />;
|
||||
case 'Campaigns':
|
||||
|
|
@ -759,7 +766,7 @@ const App: React.FC = () => {
|
|||
onResolveSubmit={handleResolveSubmit}
|
||||
/>;
|
||||
case 'WIP Reviewer':
|
||||
return <WIPReviewer dropdownOptions={dropdownOptions} />;
|
||||
return <WIPReviewer dropdownOptions={dropdownOptions} msalInstance={msalInstance} />;
|
||||
case 'Auditing':
|
||||
return <Auditing
|
||||
flaggedItems={flaggedItems}
|
||||
|
|
@ -791,8 +798,23 @@ const App: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
// Show loading spinner during MSAL authentication interactions
|
||||
if (inProgress !== InteractionStatus.None) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[#0f172a] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg className="animate-spin h-12 w-12 text-brand-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-white/80 text-sm">Authenticating...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
// Determine background color based on view to avoid grey bar on Home view
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { useMsal } from '@azure/msal-react';
|
||||
import { BarclaysLogo } from './icons/BarclaysLogo';
|
||||
import { XIcon } from './icons/XIcon';
|
||||
import { MicrosoftLogo } from './icons/MicrosoftLogo';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void;
|
||||
}
|
||||
import { loginRequest } from '../services/authConfig';
|
||||
|
||||
const SupportModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
|
|
@ -72,9 +70,11 @@ const SupportModal: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
export const Login: React.FC = () => {
|
||||
const { instance } = useMsal();
|
||||
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const handleSupportSubmit = (query: string) => {
|
||||
console.log("Support query submitted:", query);
|
||||
|
|
@ -82,12 +82,26 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|||
setIsSupportModalOpen(false);
|
||||
};
|
||||
|
||||
const handleMicrosoftLogin = () => {
|
||||
const handleMicrosoftLogin = async () => {
|
||||
setIsLoggingIn(true);
|
||||
// Simulate redirect/auth delay
|
||||
setTimeout(() => {
|
||||
onLogin();
|
||||
}, 1500);
|
||||
setLoginError(null);
|
||||
|
||||
try {
|
||||
await instance.loginPopup(loginRequest);
|
||||
// Success - MSAL Provider will detect the login and re-render App
|
||||
} catch (error: unknown) {
|
||||
console.error('Login failed:', error);
|
||||
if (error instanceof Error) {
|
||||
// Handle user cancellation differently from errors
|
||||
if (error.message.includes('user_cancelled')) {
|
||||
setLoginError(null); // Don't show error for cancellation
|
||||
} else {
|
||||
setLoginError('Login failed. Please try again or contact support.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -130,6 +144,12 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{loginError && (
|
||||
<div className="w-full p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleMicrosoftLogin}
|
||||
disabled={isLoggingIn}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { IPublicClientApplication } from '@azure/msal-browser';
|
||||
import { LogoutIcon } from './icons/LogoutIcon';
|
||||
import { QuestionMarkIcon } from './icons/QuestionMarkIcon';
|
||||
import { getUserInfo, UserInfo } from '../services/authService';
|
||||
|
||||
interface ProfileProps {
|
||||
onLogout: () => void;
|
||||
msalInstance: IPublicClientApplication;
|
||||
}
|
||||
|
||||
export const Profile: React.FC<ProfileProps> = ({ onLogout }) => {
|
||||
export const Profile: React.FC<ProfileProps> = ({ onLogout, msalInstance }) => {
|
||||
const [isQuestionFormVisible, setIsQuestionFormVisible] = useState(false);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
|
||||
const userDetails = {
|
||||
'Account Type': 'Administrator',
|
||||
'First Name': 'Steve',
|
||||
'Last Name': 'O\'Donoghue',
|
||||
'Email': 'steveodonoghue@oliver.agency',
|
||||
'Entity': 'OLIVER Agency',
|
||||
useEffect(() => {
|
||||
const info = getUserInfo(msalInstance);
|
||||
setUserInfo(info);
|
||||
}, [msalInstance]);
|
||||
|
||||
const userDetails = userInfo ? {
|
||||
'Account Type': userInfo.accountType,
|
||||
'First Name': userInfo.firstName || 'N/A',
|
||||
'Last Name': userInfo.lastName || 'N/A',
|
||||
'Email': userInfo.email,
|
||||
} : {
|
||||
'Account Type': 'Loading...',
|
||||
'First Name': '',
|
||||
'Last Name': '',
|
||||
'Email': '',
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { IPublicClientApplication } from '@azure/msal-browser';
|
||||
import type { DropdownOptions } from '../App';
|
||||
import { analyzeWIPProof, getWIPChatResponse } from '../services/geminiService';
|
||||
import type { AgentName } from '../types';
|
||||
|
|
@ -153,7 +154,12 @@ const MessageBubble: React.FC<{ sender: 'user' | 'agent'; children: React.ReactN
|
|||
|
||||
// --- MAIN COMPONENT ---
|
||||
|
||||
export const WIPReviewer: React.FC<{ dropdownOptions: DropdownOptions }> = ({ dropdownOptions }) => {
|
||||
interface WIPReviewerProps {
|
||||
dropdownOptions: DropdownOptions;
|
||||
msalInstance: IPublicClientApplication;
|
||||
}
|
||||
|
||||
export const WIPReviewer: React.FC<WIPReviewerProps> = ({ dropdownOptions, msalInstance }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: `msg_${Date.now()}`,
|
||||
|
|
@ -230,7 +236,7 @@ export const WIPReviewer: React.FC<{ dropdownOptions: DropdownOptions }> = ({ dr
|
|||
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'loading' } } : msg));
|
||||
|
||||
try {
|
||||
const summary = await analyzeWIPProof(file, () => {});
|
||||
const summary = await analyzeWIPProof(file, () => {}, msalInstance);
|
||||
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'text', text: summary } } : msg));
|
||||
} catch (err) {
|
||||
console.error("Analysis failed:", err);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,41 @@
|
|||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { PublicClientApplication, EventType, EventMessage, AuthenticationResult } from '@azure/msal-browser';
|
||||
import { MsalProvider } from '@azure/msal-react';
|
||||
import App from './App';
|
||||
import { msalConfig } from './services/authConfig';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
// Create MSAL instance
|
||||
const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
// Initialize MSAL and render app
|
||||
msalInstance.initialize().then(() => {
|
||||
// Check if there are accounts and set the first one as active
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0) {
|
||||
msalInstance.setActiveAccount(accounts[0]);
|
||||
}
|
||||
|
||||
// Listen for sign-in events to set active account
|
||||
msalInstance.addEventCallback((event: EventMessage) => {
|
||||
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
|
||||
const payload = event.payload as AuthenticationResult;
|
||||
msalInstance.setActiveAccount(payload.account);
|
||||
}
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<MsalProvider instance={msalInstance}>
|
||||
<App />
|
||||
</MsalProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
42
frontend/package-lock.json
generated
42
frontend/package-lock.json
generated
|
|
@ -8,6 +8,8 @@
|
|||
"name": "barclays-modcomms-v5-prototype",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.27.0",
|
||||
"@azure/msal-react": "^3.0.23",
|
||||
"@google/genai": "^1.20.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.1",
|
||||
|
|
@ -21,6 +23,40 @@
|
|||
"vite": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-browser": {
|
||||
"version": "4.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.27.0.tgz",
|
||||
"integrity": "sha512-bZ8Pta6YAbdd0o0PEaL1/geBsPrLEnyY/RDWqvF1PP9RUH8EMLvUMGoZFYS6jSlUan6KZ9IMTLCnwpWWpQRK/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-common": "15.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-common": {
|
||||
"version": "15.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.3.tgz",
|
||||
"integrity": "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-react": {
|
||||
"version": "3.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-3.0.23.tgz",
|
||||
"integrity": "sha512-tHvq441nwlJD9QfQP4ZStiw6xb2hQoujNHZhZb+wpUbImb3wyr2FF6/umhX/p+yzc/aq0Lee7mbdDDpzRZzxcA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@azure/msal-browser": "^4.27.0",
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
|
|
@ -2171,9 +2207,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.1",
|
||||
"@azure/msal-browser": "^4.27.0",
|
||||
"@azure/msal-react": "^3.0.23",
|
||||
"@google/genai": "^1.20.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.1",
|
||||
"html2canvas": "^1.4.1"
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
|
|
|
|||
52
frontend/services/authConfig.ts
Normal file
52
frontend/services/authConfig.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* MSAL (Microsoft Authentication Library) configuration for Azure AD SSO.
|
||||
* Uses PKCE flow by default for SPA security.
|
||||
*/
|
||||
import { Configuration, LogLevel, PopupRequest } from '@azure/msal-browser';
|
||||
|
||||
// MSAL configuration - uses PKCE by default for SPAs
|
||||
export const msalConfig: Configuration = {
|
||||
auth: {
|
||||
clientId: import.meta.env.VITE_AZURE_CLIENT_ID || '',
|
||||
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AZURE_TENANT_ID || 'common'}`,
|
||||
redirectUri: import.meta.env.VITE_AZURE_REDIRECT_URI || window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'localStorage', // Persists auth state across browser tabs/refresh
|
||||
storeAuthStateInCookie: false, // Not needed for modern browsers
|
||||
},
|
||||
system: {
|
||||
loggerOptions: {
|
||||
loggerCallback: (level, message, containsPii) => {
|
||||
if (containsPii) return;
|
||||
switch (level) {
|
||||
case LogLevel.Error:
|
||||
console.error(message);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
console.warn(message);
|
||||
break;
|
||||
case LogLevel.Info:
|
||||
console.info(message);
|
||||
break;
|
||||
case LogLevel.Verbose:
|
||||
console.debug(message);
|
||||
break;
|
||||
}
|
||||
},
|
||||
logLevel: LogLevel.Warning,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Scopes for the access token
|
||||
// Using .default for single-tenant apps to get all configured API permissions
|
||||
export const loginRequest: PopupRequest = {
|
||||
scopes: [`api://${import.meta.env.VITE_AZURE_CLIENT_ID || ''}/.default`],
|
||||
};
|
||||
|
||||
// Scopes for API calls (same as login for this app)
|
||||
export const apiTokenRequest = {
|
||||
scopes: [`api://${import.meta.env.VITE_AZURE_CLIENT_ID || ''}/.default`],
|
||||
};
|
||||
68
frontend/services/authService.ts
Normal file
68
frontend/services/authService.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Authentication service for token management and user info extraction.
|
||||
*/
|
||||
import { IPublicClientApplication, InteractionRequiredAuthError } from '@azure/msal-browser';
|
||||
import { apiTokenRequest } from './authConfig';
|
||||
|
||||
/**
|
||||
* Acquires an access token silently, or via popup if interaction required.
|
||||
* Use this before making authenticated API calls.
|
||||
*/
|
||||
export const getAccessToken = async (msalInstance: IPublicClientApplication): Promise<string | null> => {
|
||||
const account = msalInstance.getActiveAccount();
|
||||
if (!account) {
|
||||
console.error('No active account found');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try silent token acquisition first
|
||||
const response = await msalInstance.acquireTokenSilent({
|
||||
...apiTokenRequest,
|
||||
account,
|
||||
});
|
||||
return response.accessToken;
|
||||
} catch (error) {
|
||||
if (error instanceof InteractionRequiredAuthError) {
|
||||
// Fallback to popup if silent fails (e.g., token expired, new consent required)
|
||||
try {
|
||||
const response = await msalInstance.acquireTokenPopup(apiTokenRequest);
|
||||
return response.accessToken;
|
||||
} catch (popupError) {
|
||||
console.error('Failed to acquire token via popup:', popupError);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
console.error('Failed to acquire token:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User info interface matching Azure AD claims
|
||||
*/
|
||||
export interface UserInfo {
|
||||
name: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
accountType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user information from the active account
|
||||
*/
|
||||
export const getUserInfo = (msalInstance: IPublicClientApplication): UserInfo | null => {
|
||||
const account = msalInstance.getActiveAccount();
|
||||
if (!account) return null;
|
||||
|
||||
const idTokenClaims = account.idTokenClaims as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
name: account.name || 'Unknown User',
|
||||
email: account.username || '',
|
||||
firstName: (idTokenClaims?.given_name as string) || account.name?.split(' ')[0] || '',
|
||||
lastName: (idTokenClaims?.family_name as string) || account.name?.split(' ').slice(1).join(' ') || '',
|
||||
accountType: 'Enterprise User', // Single tenant = enterprise users
|
||||
};
|
||||
};
|
||||
|
|
@ -1,17 +1,27 @@
|
|||
import type { AgentReview, SubReview, AgentName } from '../types';
|
||||
import { IPublicClientApplication } from '@azure/msal-browser';
|
||||
import { getAccessToken } from './authService';
|
||||
|
||||
// WebSocket URL for backend communication
|
||||
const WS_URL = process.env.VITE_BACKEND_WS_URL || 'ws://localhost:8000/ws/analyze';
|
||||
const HTTP_URL = process.env.VITE_BACKEND_URL || 'http://localhost:8000';
|
||||
const WS_URL = import.meta.env.VITE_BACKEND_WS_URL || 'ws://localhost:8000/ws/analyze';
|
||||
const HTTP_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000';
|
||||
|
||||
/**
|
||||
* Analyze a proof using the backend WebSocket API.
|
||||
* Provides real-time updates as each agent completes.
|
||||
* Now requires MSAL instance to acquire access token.
|
||||
*/
|
||||
export const analyzeProof = async (
|
||||
file: File,
|
||||
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void
|
||||
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void,
|
||||
msalInstance: IPublicClientApplication
|
||||
): Promise<AgentReview> => {
|
||||
// Acquire token before connecting
|
||||
const accessToken = await getAccessToken(msalInstance);
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to acquire access token. Please sign in again.');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(WS_URL);
|
||||
let resolved = false;
|
||||
|
|
@ -25,7 +35,8 @@ export const analyzeProof = async (
|
|||
type: 'analyze',
|
||||
file_data: base64Data,
|
||||
file_type: file.type,
|
||||
is_wip: false
|
||||
is_wip: false,
|
||||
access_token: accessToken
|
||||
}));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
|
|
@ -95,8 +106,15 @@ export const analyzeProof = async (
|
|||
*/
|
||||
export const analyzeWIPProof = async (
|
||||
file: File,
|
||||
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void
|
||||
onAgentUpdate: (name: AgentName | 'Summary', review?: SubReview) => void,
|
||||
msalInstance: IPublicClientApplication
|
||||
): Promise<string> => {
|
||||
// Acquire token before connecting
|
||||
const accessToken = await getAccessToken(msalInstance);
|
||||
if (!accessToken) {
|
||||
throw new Error('Failed to acquire access token. Please sign in again.');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(WS_URL);
|
||||
let resolved = false;
|
||||
|
|
@ -109,7 +127,8 @@ export const analyzeWIPProof = async (
|
|||
type: 'analyze',
|
||||
file_data: base64Data,
|
||||
file_type: file.type,
|
||||
is_wip: true
|
||||
is_wip: true,
|
||||
access_token: accessToken
|
||||
}));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue