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:
michael 2025-12-16 08:43:30 -06:00
parent 3df1b9fb92
commit 321a9ca820
17 changed files with 538 additions and 60 deletions

View file

@ -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

View file

@ -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()

View file

View 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

View file

@ -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, {

View 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

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -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 = () => {

View file

@ -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);

View file

@ -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>
);
});

View file

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

View file

@ -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",

View 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`],
};

View 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
};
};

View file

@ -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 = () => {