import React, { createContext, useContext, useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { PublicClientApplication, InteractionRequiredAuthError } from '@azure/msal-browser'; import { msalConfig, loginRequest } from './authConfig'; /** * AuthProvider for Entra External ID (Customer Identity / CIAM). * * Uses @azure/msal-browser v3 with popup flow. * Supports Google, Apple, and Microsoft via identity_provider_hint. * * Exposed context shape: * isLoading — true while MSAL initializes or a login is in progress * isSignedIn — true when a user is authenticated * user — { entraSubjectId, email, displayName, firstName, surname, provider } * error — string | null * signIn(hint, isNew) — initiates login popup; hint = 'google'|'apple'|'microsoft', isNew=true forces sign-up screen * signOut() — clears session * getAccessToken()— returns a fresh ID token for authenticating API calls * clearError() — clears the error state */ // ── Provider hint → Entra External ID identity_provider_hint ───────────── // Values must match the social IdP names configured in the CIAM tenant. const PROVIDER_HINTS = { google: 'google', apple: 'apple.com', microsoft: undefined, // No hint = email/password or MSA }; // ── MSAL instance — module-level singleton ──────────────────────────────── const msalInstance = new PublicClientApplication(msalConfig); // ── Context ─────────────────────────────────────────────────────────────── const AuthContext = createContext(null); export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used within '); return ctx; } // ── Map ID token claims → user object ───────────────────────────────────── function claimsToUser(claims, provider) { return { entraSubjectId: claims.oid ?? claims.sub ?? null, email: claims.email ?? claims.preferred_username ?? null, displayName: claims.name ?? null, firstName: claims.given_name ?? null, surname: claims.family_name ?? null, provider: provider ?? 'unknown', }; } // ── Provider ────────────────────────────────────────────────────────────── export function AuthProvider({ children }) { const [isLoading, setIsLoading] = useState(true); const [user, setUser] = useState(null); const [error, setError] = useState(null); const initialized = useRef(false); const isSignedIn = !!user; // ── Initialize MSAL + handle redirect response on mount ─────────── useEffect(() => { if (initialized.current) return; initialized.current = true; (async () => { try { await msalInstance.initialize(); // Handle returning from loginRedirect const redirectResult = await msalInstance.handleRedirectPromise(); if (redirectResult?.idTokenClaims) { const provider = sessionStorage.getItem('adp_pending_provider') ?? 'unknown'; sessionStorage.removeItem('adp_pending_provider'); setUser(claimsToUser(redirectResult.idTokenClaims, provider)); setIsLoading(false); return; } // Restore existing cached account const accounts = msalInstance.getAllAccounts(); if (accounts.length > 0) { try { const silent = await msalInstance.acquireTokenSilent({ ...loginRequest, account: accounts[0], }); if (silent?.idTokenClaims) { const idp = accounts[0].idTokenClaims?.idp ?? 'unknown'; setUser(claimsToUser(silent.idTokenClaims, idp)); } } catch (silentErr) { if (silentErr instanceof InteractionRequiredAuthError) { await msalInstance.clearCache(); } else { console.warn('[Auth] Silent refresh failed:', silentErr.message); } } } } catch (err) { console.error('[Auth] MSAL init error:', err); setError('Authentication service unavailable. Please refresh the page.'); } finally { setIsLoading(false); } })(); }, []); // ── Sign in ──────────────────────────────────────────────────────── // isNewUser=true → prompt:'create' forces CIAM sign-up screen (Apply path) // isNewUser=false → standard sign-in screen (Returning path) const signIn = useCallback(async (providerHint, isNewUser = false) => { setIsLoading(true); setError(null); const hint = PROVIDER_HINTS[providerHint]; const request = { ...loginRequest, ...(isNewUser && { prompt: 'create' }), ...(hint && { extraQueryParameters: { identity_provider_hint: hint } }), }; try { const result = await msalInstance.loginPopup(request); if (result?.idTokenClaims) { setUser(claimsToUser(result.idTokenClaims, providerHint)); } } catch (popupErr) { const msg = popupErr?.message ?? ''; if (msg.includes('popup_window_error')) { try { sessionStorage.setItem('adp_pending_provider', providerHint ?? 'unknown'); await msalInstance.loginRedirect(request); return; } catch (redirectErr) { console.error('[Auth] Redirect fallback failed:', redirectErr); setError('Sign-in failed. Please try again.'); } } else if (msg.includes('user_cancelled') || msg.includes('access_denied')) { setError(null); } else { console.error('[Auth] Sign-in error:', popupErr); setError('Sign-in failed. Please try again.'); } } finally { setIsLoading(false); } }, []); // ── Get access token for API calls ──────────────────────────────── // Returns the ID token — used as Bearer token when calling the // Registration Function. The Function validates it against the CIAM // tenant and extracts the OID claim as the user's identity. const getAccessToken = useCallback(async () => { const accounts = msalInstance.getAllAccounts(); if (accounts.length === 0) throw new Error('Not signed in'); try { const result = await msalInstance.acquireTokenSilent({ ...loginRequest, account: accounts[0], }); return result.idToken; } catch (err) { if (err instanceof InteractionRequiredAuthError) { throw new Error('Session expired. Please sign in again.'); } throw err; } }, []); // ── Sign out ─────────────────────────────────────────────────────── const signOut = useCallback(async () => { const accounts = msalInstance.getAllAccounts(); setUser(null); try { if (accounts.length > 0) { await msalInstance.logoutPopup({ account: accounts[0] }); } } catch { // Local state already cleared } }, []); const clearError = useCallback(() => setError(null), []); const value = useMemo(() => ({ isLoading, isSignedIn, user, error, signIn, signOut, getAccessToken, clearError, }), [isLoading, isSignedIn, user, error, signIn, signOut, getAccessToken, clearError]); return {children}; }