import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; import { getSecuritySettings, loadRuntimeConfig } from '../config'; import { parseJwtClaims } from './jwt'; import { syncUserAccount, clearUserSyncInBrowser, isActivationPending } from '../services/userSyncService'; import { onSessionExpired, clearSessionAndRedirectToLanding } from '../services/sessionExpiration'; export interface AuthUserProfile { id: string; sub: string; name: string; email: string; role: string; tenant: string; tenant_id: number | string; } interface AuthContextType { user: AuthUserProfile | null; sub: string; claims: Record; accessToken: string | null; isAuthenticated: boolean; isLoading: boolean; isPendingActivation: boolean; login: () => Promise; logout: () => Promise; } const AuthContext = createContext(undefined); /** * Derives the user profile strictly from JWT token claims. * If name, email, tenant, or tenant_id are not present, returns null (user is signed out). */ export function deriveUserProfileFromTokenClaims(claims: Record, rawSub?: string): AuthUserProfile | null { if (!claims || typeof claims !== 'object' || Object.keys(claims).length === 0) { return null; } // Derive name const name = claims.name || claims.given_name || claims.preferred_username || claims.nickname || (claims.first_name && claims.last_name ? `${claims.first_name} ${claims.last_name}` : undefined); // Derive email const email = claims.email || claims.upn || claims.email_address || claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']; // Derive tenant (tenant name) const tenant = claims.tenant || claims.tenant_name || claims.tenantName || claims.organisation || claims.org || claims.organization; // Derive tenant_id const rawTenantId = claims.tenant_id ?? claims.tenantId ?? claims.tenantid ?? claims.org_id ?? claims.organisation_id ?? claims.organization_id ?? claims.tid; // If any required property is missing, user is signed out if (!name || !email || !tenant || rawTenantId === undefined || rawTenantId === null || String(rawTenantId).trim() === '') { return null; } const sub = claims.sub || rawSub || String(rawTenantId); const role = claims.role || (Array.isArray(claims.roles) ? claims.roles[0] : claims.roles) || 'Partner'; return { id: String(sub), sub: String(sub), name: String(name).trim(), email: String(email).trim(), role: String(role).trim(), tenant: String(tenant).trim(), tenant_id: typeof rawTenantId === 'number' ? rawTenantId : String(rawTenantId).trim(), }; } export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [accessToken, setAccessToken] = useState(() => { return typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null; }); const [claims, setClaims] = useState>({}); const [isLoading, setIsLoading] = useState(true); const [userManager, setUserManager] = useState(null); useEffect(() => { let active = true; async function initOidc() { try { await loadRuntimeConfig(); const sec = getSecuritySettings(); const authorityUrl = sec.Authority.replace(/\/+$/, ''); const dashboardRedirectUri = `${window.location.origin}/dashboard`; const mgr = new UserManager({ authority: authorityUrl, client_id: sec.ClientId, client_secret: sec.ClientSecret, redirect_uri: dashboardRedirectUri, post_logout_redirect_uri: window.location.origin, response_type: 'code', scope: sec.Scope || 'openid profile email roles pfm-api-dev', userStore: new WebStorageStateStore({ store: window.localStorage }), metadata: { issuer: authorityUrl, authorization_endpoint: `${authorityUrl}/connect/authorize`, token_endpoint: `${authorityUrl}/connect/token`, userinfo_endpoint: `${authorityUrl}/connect/userinfo`, end_session_endpoint: `${authorityUrl}/connect/endsession`, jwks_uri: `${authorityUrl}/.well-known/openid-configuration/jwks`, }, }); if (!active) return; setUserManager(mgr); // 1. Process OIDC redirect code if returning from STS if (window.location.search.includes('code=')) { try { const oidcUser = await mgr.signinCallback(); if (oidcUser && oidcUser.access_token) { const tokenClaims = parseJwtClaims(oidcUser.access_token); const combinedClaims = { ...tokenClaims, ...(oidcUser.profile || {}) }; const derivedProfile = deriveUserProfileFromTokenClaims(combinedClaims, oidcUser.profile?.sub); if (derivedProfile) { localStorage.setItem('pfm_access_token', oidcUser.access_token); localStorage.setItem('pfm_user_sub', derivedProfile.sub); localStorage.setItem('pfm_auth_user', JSON.stringify(derivedProfile)); localStorage.setItem('pfm_user_claims', JSON.stringify(combinedClaims)); setAccessToken(oidcUser.access_token); setClaims(combinedClaims); setUser(derivedProfile); try { await syncUserAccount(derivedProfile.sub, oidcUser.access_token, derivedProfile); } catch {} } else { clearUserSyncInBrowser(); localStorage.removeItem('pfm_access_token'); localStorage.removeItem('pfm_auth_user'); setAccessToken(null); setUser(null); } } window.history.replaceState({}, document.title, window.location.pathname); } catch (err) { console.error('[Auth] Callback error:', err); } } else { // 2. Evaluate existing stored token const storedToken = localStorage.getItem('pfm_access_token'); if (storedToken) { const tokenClaims = parseJwtClaims(storedToken); const derivedProfile = deriveUserProfileFromTokenClaims(tokenClaims); if (derivedProfile) { setAccessToken(storedToken); setClaims(tokenClaims); setUser(derivedProfile); localStorage.setItem('pfm_auth_user', JSON.stringify(derivedProfile)); } else { clearUserSyncInBrowser(); localStorage.removeItem('pfm_access_token'); localStorage.removeItem('pfm_auth_user'); setAccessToken(null); setUser(null); } } else { setUser(null); setAccessToken(null); } } } catch (err) { console.warn('[Auth] OIDC initialization notice:', err); setUser(null); setAccessToken(null); } finally { if (active) setIsLoading(false); } } initOidc(); const unsubSession = onSessionExpired(() => clearSessionAndRedirectToLanding()); return () => { active = false; unsubSession(); }; }, []); const login = async () => { if (userManager) { try { await userManager.signinRedirect({ redirect_uri: `${window.location.origin}/dashboard`, }); } catch (err) { console.error('[Auth] Redirect error:', err); } } }; const logout = async () => { clearUserSyncInBrowser(); clearSessionAndRedirectToLanding(); }; return ( {children} ); }; export const useAuth = () => { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used within AuthProvider'); return ctx; };