Files
AdPlatform-Client/Client-Registration/src/auth/AuthProvider.jsx
Grae Jones 23fc92bfb6
All checks were successful
Client Registration / build-deploy (push) Successful in 10s
...apply 3
2026-03-23 10:25:12 -07:00

203 lines
8.7 KiB
JavaScript

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 <AuthProvider>');
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}