mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
fix: multi-network OAuth login for all cloud portals
- OAuth callback accepts redirect_uri from frontend to derive correct IAM client_id per network (all use lux-web3 shared app) - Token verification accepts tokens from any known IAM app audience - Frontend sends redirect_uri in callback POST body - Login page uses proper /login/oauth/authorize URL with client_id - Brand configs unified to use lux-web3 IAM app for all networks - Removed dead code (duplicate get_current_user, unreachable methods) - IAM init data updated with cloud redirect URIs for pars/zoo/hanzo
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
"""OAuth callback endpoint for Hanzo IAM integration."""
|
||||
"""OAuth callback endpoint for Hanzo IAM integration.
|
||||
|
||||
Supports multi-network OAuth: each white-label network (lux, pars, zoo, hanzo)
|
||||
has its own IAM app with a distinct client_id. The frontend sends the
|
||||
redirect_uri it used for the authorize request so the backend can derive the
|
||||
correct client_id for the token exchange.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -10,12 +17,35 @@ from bootnode.core.iam import IAMClient, IAMUser, get_current_user
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
# All cloud networks share the same IAM app (app-lux-web3, clientId=lux-web3)
|
||||
# with redirect URIs for each network registered in IAM.
|
||||
NETWORK_CLIENT_IDS: dict[str, str] = {
|
||||
"lux": "lux-web3",
|
||||
"pars": "lux-web3",
|
||||
"zoo": "lux-web3",
|
||||
"hanzo": "lux-web3",
|
||||
}
|
||||
|
||||
|
||||
def _network_from_redirect_uri(redirect_uri: str) -> str | None:
|
||||
"""Extract network name from a redirect_uri like https://cloud.lux.network/…"""
|
||||
host = urlparse(redirect_uri).hostname or ""
|
||||
# cloud.lux.network → lux | cloud.hanzo.ai → hanzo
|
||||
parts = host.split(".")
|
||||
if len(parts) >= 3 and parts[0] == "cloud":
|
||||
return parts[1]
|
||||
# bootno.de special case → lux (primary brand)
|
||||
if "bootno.de" in host:
|
||||
return "lux"
|
||||
return None
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
"""OAuth callback request."""
|
||||
|
||||
code: str
|
||||
state: str
|
||||
redirect_uri: str = "" # Frontend should send the redirect_uri it used
|
||||
|
||||
|
||||
class OAuthCallbackResponse(BaseModel):
|
||||
@@ -28,57 +58,60 @@ class OAuthCallbackResponse(BaseModel):
|
||||
|
||||
@router.post("/oauth/callback", response_model=OAuthCallbackResponse)
|
||||
async def oauth_callback(request: OAuthCallbackRequest) -> OAuthCallbackResponse:
|
||||
"""Handle OAuth callback from Hanzo IAM."""
|
||||
"""Handle OAuth callback from Hanzo IAM.
|
||||
|
||||
The frontend MUST send redirect_uri so we can derive the correct IAM
|
||||
client_id for multi-network token exchange.
|
||||
"""
|
||||
try:
|
||||
# Exchange authorization code for access token
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
f"{settings.iam_url}/oauth2/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": request.code,
|
||||
"client_id": settings.iam_client_id,
|
||||
"client_secret": settings.iam_client_secret,
|
||||
"redirect_uri": f"{settings.frontend_url}/auth/callback",
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
# Determine redirect_uri — prefer explicit, fall back to settings
|
||||
redirect_uri = request.redirect_uri or (
|
||||
f"{settings.frontend_url.rstrip('/')}/auth/callback"
|
||||
)
|
||||
|
||||
# Derive network-specific client_id from the redirect_uri domain
|
||||
network = _network_from_redirect_uri(redirect_uri)
|
||||
client_id = NETWORK_CLIENT_IDS.get(network or "", settings.iam_client_id)
|
||||
|
||||
iam_client = IAMClient()
|
||||
token_data = await iam_client.exchange_code(
|
||||
code=request.code,
|
||||
redirect_uri=redirect_uri,
|
||||
client_id=client_id,
|
||||
)
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="OAuth token response missing access_token",
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code for token",
|
||||
)
|
||||
# Verify token and get user info
|
||||
user = await iam_client.verify_token(access_token)
|
||||
|
||||
token_data = token_response.json()
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
# Verify token and get user info
|
||||
iam_client = IAMClient()
|
||||
user = await iam_client.verify_token(access_token)
|
||||
|
||||
# Check if user's organization is allowed
|
||||
if user.org not in settings.allowed_orgs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Organization '{user.org}' is not allowed",
|
||||
)
|
||||
|
||||
return OAuthCallbackResponse(
|
||||
access_token=access_token,
|
||||
expires_in=token_data.get("expires_in", 3600),
|
||||
# Check if user's organization is allowed
|
||||
if user.org not in settings.allowed_orgs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Organization '{user.org}' is not allowed",
|
||||
)
|
||||
|
||||
return OAuthCallbackResponse(
|
||||
access_token=access_token,
|
||||
expires_in=token_data.get("expires_in", 3600),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"OAuth callback failed: {str(e)}",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to exchange authorization code for token",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(user: IAMUser = Depends(get_current_user)):
|
||||
"""Get current authenticated user from Hanzo IAM."""
|
||||
return user
|
||||
return user
|
||||
|
||||
+24
-58
@@ -61,19 +61,27 @@ class IAMClient:
|
||||
self._jwks_client = PyJWKClient(jwks_url)
|
||||
return self._jwks_client
|
||||
|
||||
# All known IAM app client_ids — tokens from any of these are accepted.
|
||||
KNOWN_CLIENT_IDS = {"lux-web3", "pars-cloud", "zoo-cloud", "hanzo-cloud"}
|
||||
|
||||
async def verify_token(self, token: str) -> IAMUser:
|
||||
"""Verify JWT token from hanzo.id and return user."""
|
||||
try:
|
||||
# Get signing key from JWKS
|
||||
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Accept tokens issued for any of our known IAM apps
|
||||
allowed_audiences = self.KNOWN_CLIENT_IDS | {self.settings.iam_client_id}
|
||||
|
||||
# Decode and verify token
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256", "ES256"],
|
||||
audience=self.settings.iam_client_id,
|
||||
issuer=self.settings.iam_url,
|
||||
audience=list(allowed_audiences),
|
||||
# Lux IAM currently issues tokens with canonical issuer
|
||||
# (for example, https://hanzo.id) even when reached via branded domains.
|
||||
options={"verify_iss": False},
|
||||
)
|
||||
|
||||
# Extract user info
|
||||
@@ -101,14 +109,25 @@ class IAMClient:
|
||||
detail=f"Invalid token: {str(e)}",
|
||||
)
|
||||
|
||||
async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]:
|
||||
"""Exchange authorization code for tokens."""
|
||||
async def exchange_code(
|
||||
self,
|
||||
code: str,
|
||||
redirect_uri: str,
|
||||
client_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Exchange authorization code for tokens.
|
||||
|
||||
Args:
|
||||
code: The authorization code from the OAuth callback.
|
||||
redirect_uri: Must match the redirect_uri used in the authorize request.
|
||||
client_id: IAM app client_id. Defaults to settings.iam_client_id.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.settings.iam_url}/api/login/oauth/access_token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.settings.iam_client_id,
|
||||
"client_id": client_id or self.settings.iam_client_id,
|
||||
"client_secret": self.settings.iam_client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
@@ -124,59 +143,6 @@ class IAMClient:
|
||||
return response.json()
|
||||
|
||||
|
||||
# Global IAM client instance
|
||||
iam_client = IAMClient()
|
||||
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer())) -> IAMUser:
|
||||
"""Dependency to get current authenticated user from Hanzo IAM."""
|
||||
return await iam_client.verify_token(credentials.credentials)
|
||||
|
||||
async def get_user_info(self, access_token: str) -> IAMUser:
|
||||
"""Get user info from IAM using access token."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.settings.iam_url}/api/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Failed to get user info",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
return IAMUser(
|
||||
id=data.get("sub"),
|
||||
name=data.get("name", ""),
|
||||
email=data.get("email", ""),
|
||||
org=data.get("org", "hanzo"),
|
||||
avatar=data.get("avatar"),
|
||||
roles=data.get("roles", []),
|
||||
permissions=data.get("permissions", []),
|
||||
)
|
||||
|
||||
def get_login_url(self, redirect_uri: str, org: str = "hanzo") -> str:
|
||||
"""Get login URL for specific org."""
|
||||
org_domains = {
|
||||
"hanzo": "hanzo.id",
|
||||
"zoo": "zoo.id",
|
||||
"lux": "lux.id",
|
||||
"pars": "pars.id",
|
||||
}
|
||||
domain = org_domains.get(org, "hanzo.id")
|
||||
|
||||
return (
|
||||
f"https://{domain}/login/oauth/authorize"
|
||||
f"?client_id={self.settings.iam_client_id}"
|
||||
f"&response_type=code"
|
||||
f"&redirect_uri={redirect_uri}"
|
||||
f"&scope=openid+profile+email"
|
||||
f"&state={org}"
|
||||
)
|
||||
|
||||
|
||||
# Dependency injection
|
||||
@lru_cache
|
||||
def get_iam_client() -> IAMClient:
|
||||
|
||||
@@ -45,12 +45,13 @@ function CallbackContent() {
|
||||
const apiUrl = (typeof window !== "undefined" && window.location.hostname !== "localhost")
|
||||
? brand.apiUrl
|
||||
: (process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000")
|
||||
const redirectUri = `${window.location.origin}/auth/callback`
|
||||
const response = await fetch(`${apiUrl}/v1/auth/oauth/callback`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ code, state: stateParam }),
|
||||
body: JSON.stringify({ code, state: stateParam, redirect_uri: redirectUri }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -38,10 +38,11 @@ function LoginForm() {
|
||||
const returnUrl = searchParams.get("returnUrl") || "/dashboard"
|
||||
const callbackUrl = `${window.location.origin}/auth/callback`
|
||||
const iamUrl = brand.iam.url
|
||||
const authRedirect = `${iamUrl}/login?redirect_uri=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(returnUrl)}`
|
||||
const clientId = brand.iam.clientId
|
||||
const authRedirect = `${iamUrl}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=openid+profile+email&state=${encodeURIComponent(returnUrl)}`
|
||||
window.location.href = authRedirect
|
||||
}
|
||||
}, [isProduction, authLoading, user, searchParams, brand.iam.url])
|
||||
}, [isProduction, authLoading, user, searchParams, brand.iam.url, brand.iam.clientId])
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
+5
-5
@@ -134,7 +134,7 @@ const brands: Record<string, BrandConfig> = {
|
||||
},
|
||||
iam: {
|
||||
url: "https://iam.lux.network",
|
||||
clientId: "lux-cloud",
|
||||
clientId: "lux-web3",
|
||||
domain: "lux.id",
|
||||
},
|
||||
footerLinks: [
|
||||
@@ -170,8 +170,8 @@ const brands: Record<string, BrandConfig> = {
|
||||
github: "https://github.com/pars-network",
|
||||
},
|
||||
iam: {
|
||||
url: "https://hanzo.id",
|
||||
clientId: "pars-cloud",
|
||||
url: "https://iam.lux.network",
|
||||
clientId: "lux-web3",
|
||||
domain: "pars.id",
|
||||
},
|
||||
footerLinks: [
|
||||
@@ -206,8 +206,8 @@ const brands: Record<string, BrandConfig> = {
|
||||
github: "https://github.com/zoolabs",
|
||||
},
|
||||
iam: {
|
||||
url: "https://hanzo.id",
|
||||
clientId: "zoo-web3",
|
||||
url: "https://iam.lux.network",
|
||||
clientId: "lux-web3",
|
||||
domain: "zoo.id",
|
||||
},
|
||||
footerLinks: [
|
||||
|
||||
Reference in New Issue
Block a user