feat(auth): add IAM login via hanzo.id for user accounts

Add OIDC authentication support using hanzo.id as the identity
provider for Lux Explorer user accounts.

- Add 'oidc' auth provider type alongside existing auth0/dynamic
- AuthGuardOidc: redirect-based OIDC login flow with CSRF state
- pages/auth/callback: authorization code exchange, token storage
- useApiFetch: pass Bearer token for OIDC-authenticated API calls
- account feature config: support NEXT_PUBLIC_OIDC_SERVER_URL and
  NEXT_PUBLIC_OIDC_CLIENT_ID environment variables
- .env.production: configure hanzo.id as default OIDC provider
This commit is contained in:
Zoo Queen
2026-02-22 15:15:24 -08:00
parent c10466e118
commit 694e6d12ea
12 changed files with 268 additions and 3 deletions
+5
View File
@@ -4,3 +4,8 @@ NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
# OIDC Authentication (hanzo.id)
NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=oidc
NEXT_PUBLIC_OIDC_SERVER_URL=https://hanzo.id
NEXT_PUBLIC_OIDC_CLIENT_ID=lux-explore-client-id
+3
View File
@@ -34,6 +34,9 @@ NEXT_PUBLIC_COLOR_THEME_DEFAULT=dark
# Features
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://hanzo.id
NEXT_PUBLIC_OIDC_CLIENT_ID=lux-explore-client-id
# Stats
NEXT_PUBLIC_STATS_API_HOST=https://api-explore.lux.network/api/stats
+31
View File
@@ -13,6 +13,10 @@ const config: Feature<{
dynamic?: {
environmentId: string;
};
oidc?: {
serverUrl: string;
clientId: string;
};
}> = (() => {
if (
@@ -21,6 +25,8 @@ const config: Feature<{
) {
const authProvider = getEnvValue('NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER');
const dynamicEnvironmentId = getEnvValue('NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID');
const oidcServerUrl = getEnvValue('NEXT_PUBLIC_OIDC_SERVER_URL');
const oidcClientId = getEnvValue('NEXT_PUBLIC_OIDC_CLIENT_ID');
if (authProvider === 'dynamic' && dynamicEnvironmentId) {
return Object.freeze({
@@ -33,6 +39,18 @@ const config: Feature<{
});
}
if (authProvider === 'oidc' && oidcServerUrl && oidcClientId) {
return Object.freeze({
title,
isEnabled: true,
authProvider: 'oidc',
oidc: {
serverUrl: oidcServerUrl,
clientId: oidcClientId,
},
});
}
if (services.reCaptchaV2.siteKey) {
return Object.freeze({
title,
@@ -40,6 +58,19 @@ const config: Feature<{
authProvider: 'auth0',
});
}
// Fallback: if OIDC env vars are set without explicit provider, enable OIDC
if (oidcServerUrl && oidcClientId) {
return Object.freeze({
title,
isEnabled: true,
authProvider: 'oidc',
oidc: {
serverUrl: oidcServerUrl,
clientId: oidcClientId,
},
});
}
}
return Object.freeze({
+11 -1
View File
@@ -5,6 +5,7 @@ import React from 'react';
import type { CsrfData } from 'types/client/account';
import type { ExternalChainExtended } from 'types/externalChains';
import config from 'configs/app';
import isBodyAllowed from 'lib/api/isBodyAllowed';
import isNeedProxy from 'lib/api/isNeedProxy';
import { getResourceKey } from 'lib/api/useApiQuery';
@@ -44,7 +45,16 @@ export default function useApiFetch() {
const withBody = isBodyAllowed(fetchParams?.method);
const headers = pickBy({
'x-endpoint': isNeedProxy() ? api.endpoint : undefined,
Authorization: [ 'admin', 'contractInfo' ].includes(apiName) ? apiToken : undefined,
Authorization: (() => {
const feature = config.features.account;
if (feature.isEnabled && feature.authProvider === 'oidc' && apiToken && apiName === 'general') {
return `Bearer ${ apiToken }`;
}
if ([ 'admin', 'contractInfo' ].includes(apiName)) {
return apiToken;
}
return undefined;
})(),
'x-csrf-token': [ 'general', 'admin', 'contractInfo' ].includes(apiName) && withBody && csrfToken ? csrfToken : undefined,
...(apiName === 'general' ? {
'api-v2-temp-token': apiTempToken,
+9
View File
@@ -27,6 +27,15 @@ export const accountAuth0: Guard = (chainConfig: typeof config) => async() => {
}
};
export const accountOidc: Guard = (chainConfig: typeof config) => async() => {
const feature = chainConfig.features.account;
if (!feature.isEnabled || feature.authProvider !== 'oidc') {
return {
notFound: true,
};
}
};
export const verifiedAddresses: Guard = (chainConfig: typeof config) => async() => {
if (!chainConfig.features.addressVerification.isEnabled) {
return {
+1
View File
@@ -7,6 +7,7 @@ export const tx = factory([ guards.notOpSuperchain ]);
export const token = factory([ guards.notOpSuperchain ]);
export const account = factory([ guards.account ]);
export const accountAuth0 = factory([ guards.accountAuth0 ]);
export const accountOidc = factory([ guards.accountOidc ]);
export const verifiedAddresses = factory([ guards.account, guards.verifiedAddresses ]);
export const userOps = factory([ guards.userOps ]);
export const marketplace = factory([ guards.marketplace ]);
+1
View File
@@ -29,6 +29,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/api-docs">
| DynamicRoute<"/apps/[id]", { "id": string }>
| StaticRoute<"/apps">
| StaticRoute<"/auth/callback">
| StaticRoute<"/auth/profile">
| DynamicRoute<"/batches/[number]", { "number": string }>
| DynamicRoute<"/batches/celestia/[height]/[commitment]", { "height": string; "commitment": string }>
+121
View File
@@ -0,0 +1,121 @@
import { Center, Flex, Spinner, Text } from '@chakra-ui/react';
import type { NextPage } from 'next';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import * as cookies from 'lib/cookies';
import PageNextJs from 'nextjs/PageNextJs';
const COOKIE_MAX_AGE_DAYS = 7;
const OidcCallback: NextPage = () => {
const router = useRouter();
const [ error, setError ] = React.useState<string | null>(null);
React.useEffect(() => {
const feature = config.features.account;
if (!feature.isEnabled || feature.authProvider !== 'oidc' || !feature.oidc) {
setError('OIDC authentication is not configured');
return;
}
const { code, state, error: oauthError, error_description: oauthErrorDesc } = router.query;
if (oauthError) {
setError(typeof oauthErrorDesc === 'string' ? oauthErrorDesc : String(oauthError));
return;
}
if (!code || typeof code !== 'string') {
// Query params not yet populated on initial render
return;
}
// Verify CSRF state
const savedState = sessionStorage.getItem('oidc_state');
if (savedState && state !== savedState) {
setError('Invalid state parameter - possible CSRF attack');
return;
}
sessionStorage.removeItem('oidc_state');
const exchangeCode = async() => {
try {
const { serverUrl, clientId } = feature.oidc;
const redirectUri = `${ window.location.origin }/auth/callback`;
const tokenResponse = await fetch(`${ serverUrl }/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
}),
});
if (!tokenResponse.ok) {
const errorBody = await tokenResponse.text();
throw new Error(`Token exchange failed: ${ errorBody }`);
}
const tokenData = await tokenResponse.json() as {
access_token: string;
id_token?: string;
refresh_token?: string;
expires_in?: number;
};
// Store the access token in the API_TOKEN cookie
const expiresInDays = tokenData.expires_in
? Math.ceil(tokenData.expires_in / 86400)
: COOKIE_MAX_AGE_DAYS;
cookies.set(cookies.NAMES.API_TOKEN, tokenData.access_token, { expires: expiresInDays });
// Redirect to profile page
router.replace('/auth/profile');
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
}
};
exchangeCode();
}, [ router ]);
if (error) {
return (
<PageNextJs pathname="/auth/callback">
<Center minH="50vh">
<Flex direction="column" alignItems="center" gap={ 4 }>
<Text fontSize="xl" fontWeight="bold" color="red.500">Authentication Error</Text>
<Text color="gray.400">{ error }</Text>
<Text
as="a"
href="/"
color="blue.400"
_hover={{ textDecoration: 'underline' }}
>
Return to home
</Text>
</Flex>
</Center>
</PageNextJs>
);
}
return (
<PageNextJs pathname="/auth/callback">
<Center minH="50vh">
<Flex direction="column" alignItems="center" gap={ 4 }>
<Spinner size="xl"/>
<Text fontSize="lg">Completing sign in...</Text>
</Flex>
</Center>
</PageNextJs>
);
};
export default OidcCallback;
+1 -1
View File
@@ -15,4 +15,4 @@ const Page: NextPage = () => {
export default Page;
export { accountAuth0 as getServerSideProps } from 'nextjs/getServerSideProps/main';
export { account as getServerSideProps } from 'nextjs/getServerSideProps/main';
+1 -1
View File
@@ -2,4 +2,4 @@ export interface CsrfData {
token: string;
}
export type AuthProvider = 'auth0' | 'dynamic';
export type AuthProvider = 'auth0' | 'dynamic' | 'oidc';
+5
View File
@@ -4,6 +4,7 @@ import config from 'configs/app';
const AuthGuardAuth0 = dynamic(() => import('./AuthGuardAuth0'), { ssr: false });
const AuthGuardDynamic = dynamic(() => import('./AuthGuardDynamic'), { ssr: false });
const AuthGuardOidc = dynamic(() => import('./AuthGuardOidc'), { ssr: false });
const feature = config.features.account;
@@ -12,6 +13,10 @@ const AuthGuard = (() => {
return AuthGuardDynamic;
}
if (feature.isEnabled && feature.authProvider === 'oidc') {
return AuthGuardOidc;
}
return AuthGuardAuth0;
})();
+79
View File
@@ -0,0 +1,79 @@
import React from 'react';
import config from 'configs/app';
import * as cookies from 'lib/cookies';
import useProfileQuery from '../useProfileQuery';
interface InjectedProps {
onClick: () => void;
}
interface Props {
children: (props: InjectedProps) => React.ReactNode;
onAuthSuccess: () => void;
ensureEmail?: boolean;
}
function getOidcLoginUrl(): string {
const feature = config.features.account;
if (!feature.isEnabled || feature.authProvider !== 'oidc' || !feature.oidc) {
return '';
}
const { serverUrl, clientId } = feature.oidc;
const redirectUri = `${ window.location.origin }/auth/callback`;
const state = crypto.randomUUID();
// Store state for CSRF protection
sessionStorage.setItem('oidc_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: 'openid profile email',
state,
});
return `${ serverUrl }/oauth/authorize?${ params.toString() }`;
}
const AuthGuardOidc = ({ children, onAuthSuccess, ensureEmail }: Props) => {
const profileQuery = useProfileQuery();
const handleClick = React.useCallback(() => {
if (profileQuery.data) {
if (ensureEmail && !profileQuery.data.email) {
// User is logged in but has no email -- redirect to login again
const loginUrl = getOidcLoginUrl();
if (loginUrl) {
window.location.href = loginUrl;
}
} else {
onAuthSuccess();
}
} else {
// Check if we have a token cookie (user might have logged in via callback)
const apiToken = cookies.get(cookies.NAMES.API_TOKEN);
if (apiToken) {
onAuthSuccess();
return;
}
// Redirect to IAM login
const loginUrl = getOidcLoginUrl();
if (loginUrl) {
window.location.href = loginUrl;
}
}
}, [ profileQuery.data, ensureEmail, onAuthSuccess ]);
return (
<>
{ children({ onClick: handleClick }) }
</>
);
};
export default React.memo(AuthGuardOidc);