203 lines
8.7 KiB
JavaScript
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>;
|
|
}
|