feat: native Hanzo IAM integration for lux.id OIDC login

- Fix token exchange endpoint to /api/login/oauth/access_token (IAM convention)
- Fix authorize URL to /login/oauth/authorize (consistent across components)
- Use lux-app-client-id (registered in IAM) instead of unregistered 'explore'
- Fetch user profile directly from IAM /api/userinfo endpoint
- Skip Blockscout auth_logout API call for OIDC provider
- Hide "scanning new transactions" warning (looks like error on low-traffic chains)
- Fix type error in useRedirectForInvalidAuthToken for OIDC error shape
This commit is contained in:
2026-03-01 23:02:59 -08:00
parent 0a8c8e0ea1
commit 25d548576d
7 changed files with 72 additions and 12 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ NEXT_PUBLIC_HOMEPAGE_CHARTS=["daily_txs", "coin_price", "market_cap"]
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=oidc
NEXT_PUBLIC_OIDC_SERVER_URL=https://lux.id
NEXT_PUBLIC_OIDC_CLIENT_ID=explore
NEXT_PUBLIC_OIDC_CLIENT_ID=lux-app-client-id
# Stats
NEXT_PUBLIC_STATS_API_HOST=https://api-explore.lux.network/api/stats
+1 -1
View File
@@ -51,7 +51,7 @@ const OidcCallback: NextPage = () => {
const { serverUrl, clientId } = oidc;
const redirectUri = `${ window.location.origin }/auth/callback`;
const tokenResponse = await fetch(`${ serverUrl }/oauth/token`, {
const tokenResponse = await fetch(`${ serverUrl }/api/login/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
+6 -2
View File
@@ -61,7 +61,7 @@ const SocketNewItemsNotice = chakra(({ children, className, url, num, showErrorA
}
if (!num) {
return `scanning new ${ name }s...`;
return null;
}
if (type === 'cross_chain_transaction') {
@@ -78,10 +78,14 @@ const SocketNewItemsNotice = chakra(({ children, className, url, num, showErrorA
);
})();
if (!alertContent && !isLoading) {
return null;
}
const content = !isLoading ? (
<Alert
className={ className }
status={ showErrorAlert || !num ? 'warning_table' : 'info' }
status={ showErrorAlert ? 'warning_table' : 'info' }
px={ 4 }
py="6px"
textStyle="sm"
+1 -1
View File
@@ -36,7 +36,7 @@ function getOidcLoginUrl(): string {
state,
});
return `${ serverUrl }/oauth/authorize?${ params.toString() }`;
return `${ serverUrl }/login/oauth/authorize?${ params.toString() }`;
}
const AuthGuardOidc = ({ children, onAuthSuccess, ensureEmail }: Props) => {
+8 -1
View File
@@ -30,7 +30,11 @@ export default function useLogout() {
return React.useCallback(async() => {
try {
await apiFetch('general:auth_logout');
const accountFeature = config.features.account;
const isOidc = accountFeature.isEnabled && accountFeature.authProvider === 'oidc';
if (!isOidc) {
await apiFetch('general:auth_logout');
}
cookies.remove(cookies.NAMES.API_TOKEN);
if (config.features.rewards.isEnabled) {
@@ -51,6 +55,9 @@ export default function useLogout() {
queryKey: getResourceKey('general:user_info'),
exact: true,
});
queryClient.resetQueries({
queryKey: [ 'oidc_profile' ],
});
queryClient.resetQueries({
queryKey: getResourceKey('general:custom_abi'),
exact: true,
+53 -5
View File
@@ -1,12 +1,60 @@
import { useQuery } from '@tanstack/react-query';
import type { UserInfo } from 'types/api/account';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies';
export default function useProfileQuery() {
return useApiQuery('general:user_info', {
queryOptions: {
refetchOnMount: false,
enabled: config.features.account.isEnabled && Boolean(cookies.get(cookies.NAMES.API_TOKEN)),
interface OidcUserInfo {
readonly sub?: string;
readonly name?: string;
readonly preferred_username?: string;
readonly displayName?: string;
readonly email?: string;
readonly picture?: string;
readonly avatar?: string;
}
function useOidcProfileQuery() {
const feature = config.features.account;
const serverUrl = feature.isEnabled && feature.authProvider === 'oidc' && feature.oidc ? feature.oidc.serverUrl : '';
const token = cookies.get(cookies.NAMES.API_TOKEN);
return useQuery<UserInfo>({
queryKey: [ 'oidc_profile', serverUrl ],
queryFn: async() => {
const response = await fetch(`${ serverUrl }/api/userinfo`, {
headers: { Authorization: `Bearer ${ token }` },
});
if (!response.ok) {
throw new Error('Failed to fetch profile');
}
const data = await response.json() as OidcUserInfo;
return {
name: data.name || data.displayName || undefined,
nickname: data.preferred_username || undefined,
email: data.email || null,
address_hash: null,
avatar: data.picture || data.avatar || undefined,
};
},
refetchOnMount: false,
enabled: Boolean(serverUrl && token),
});
}
export default function useProfileQuery() {
const feature = config.features.account;
const isOidc = feature.isEnabled && feature.authProvider === 'oidc';
const oidcQuery = useOidcProfileQuery();
const blockscoutQuery = useApiQuery('general:user_info', {
queryOptions: {
refetchOnMount: false,
enabled: !isOidc && feature.isEnabled && Boolean(cookies.get(cookies.NAMES.API_TOKEN)),
},
});
return isOidc ? oidcQuery : blockscoutQuery;
}
@@ -7,7 +7,8 @@ import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
export default function useRedirectForInvalidAuthToken() {
const rollbar = useRollbar();
const profileQuery = useProfileQuery();
const errorStatus = profileQuery.error?.status;
const error = profileQuery.error;
const errorStatus = error && 'status' in error ? (error as { status: number }).status : undefined;
React.useEffect(() => {
if (errorStatus === 401) {