mirror of
https://github.com/luxfi/explore.git
synced 2026-07-27 03:13:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ]);
|
||||
|
||||
Vendored
+1
@@ -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 }>
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -2,4 +2,4 @@ export interface CsrfData {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type AuthProvider = 'auth0' | 'dynamic';
|
||||
export type AuthProvider = 'auth0' | 'dynamic' | 'oidc';
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user