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:
2026-02-11 18:44:57 -08:00
parent 2093d6fd97
commit 37d5391a38
5 changed files with 107 additions and 106 deletions
+73 -40
View File
@@ -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
View File
@@ -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:
+2 -1
View File
@@ -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) {
+3 -2
View File
@@ -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
View File
@@ -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: [