255 lines
8.3 KiB
TypeScript
255 lines
8.3 KiB
TypeScript
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<string, any>;
|
|
accessToken: string | null;
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
isPendingActivation: boolean;
|
|
login: () => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextType | undefined>(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<string, any>, 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<AuthUserProfile | null>(null);
|
|
const [accessToken, setAccessToken] = useState<string | null>(() => {
|
|
return typeof window !== 'undefined' ? localStorage.getItem('pfm_access_token') : null;
|
|
});
|
|
const [claims, setClaims] = useState<Record<string, any>>({});
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [userManager, setUserManager] = useState<UserManager | null>(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 (
|
|
<AuthContext.Provider
|
|
value={{
|
|
user,
|
|
sub: user?.sub || '',
|
|
claims,
|
|
accessToken,
|
|
isAuthenticated: !!user && !!accessToken,
|
|
isLoading,
|
|
isPendingActivation: isActivationPending(),
|
|
login,
|
|
logout,
|
|
}}
|
|
>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useAuth = () => {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
|
return ctx;
|
|
};
|