mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
Two defects found by capturing the console against production.
1. Every custody write path was dark because a VALID IAM token was
rejected. fetchUserInfo built <issuer>/oauth/userinfo, which the SPA
shell answers 200 text/html; the JSON decode then failed on '<' and
surfaced as 401 "failed to decode userinfo: invalid character '<'".
The issuer serves userinfo at /v1/iam/oauth/userinfo (401 JSON when
unauthenticated, as it should). The provider allowlist keys on the
issuer ORIGIN, so the /v1/iam prefix cannot be supplied by the caller
and belongs in this function.
Verified by hand against lux.id: authorization_code + S256 completes,
token endpoint 200, and userinfo at the /v1/iam path returns
{"email":"[email protected]","isAdmin":true,"owner":"lux"}.
2. /wallets/[id]/smart-wallets rendered two invented wallets: a "2-of-3"
Safe at 0x1234...abcd on Ethereum and an ERC-4337 account at
0x5678...efgh on 96369, both green and marked deployed, hardcoded
under a "TODO: fetch from API". They rendered for ANY key id.
A custody screen claiming a 2-of-3 multisig that exists on no chain is
not a placeholder, it is a false statement about where funds are — and
it contradicted lux.cloud's own honest "1 of 1 today, no ring
deployed". The list is now empty until the API is read.
The login page needs no change: it already uses @hanzo/iam startLogin
with PKCE. Production is serving an older build than main, which is why
it still requested the implicit response_type=token that this repo
removed.
Still required and NOT done here: register
https://mpc.lux.cloud/auth/callback on the lux-mpc IAM client, and
deploy. Neither is a code change.
197 lines
5.5 KiB
Go
197 lines
5.5 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/orm"
|
|
|
|
"github.com/luxfi/mpc/pkg/db"
|
|
)
|
|
|
|
// oidcUserInfo represents the response from an OIDC userinfo endpoint.
|
|
type oidcUserInfo struct {
|
|
Sub string `json:"sub"`
|
|
Name string `json:"name"`
|
|
PreferredUsername string `json:"preferred_username"`
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
// handleOIDCExchange exchanges an external OIDC access token for a local MPC API JWT.
|
|
// POST /v1/auth/oidc
|
|
// Body: {"access_token": "...", "provider_url": "https://lux.id"}
|
|
func (s *Server) handleOIDCExchange(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
AccessToken string `json:"access_token"`
|
|
ProviderURL string `json:"provider_url"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.AccessToken == "" {
|
|
writeError(w, http.StatusBadRequest, "access_token is required")
|
|
return
|
|
}
|
|
if req.ProviderURL == "" {
|
|
writeError(w, http.StatusBadRequest, "provider_url is required")
|
|
return
|
|
}
|
|
|
|
// Validate provider is in the allowlist
|
|
if !s.isAllowedOIDCIssuer(req.ProviderURL) {
|
|
writeError(w, http.StatusBadRequest, "provider not allowed")
|
|
return
|
|
}
|
|
|
|
// Call the provider's userinfo endpoint
|
|
userInfo, err := fetchUserInfo(req.ProviderURL, req.AccessToken)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "failed to validate token: "+err.Error())
|
|
return
|
|
}
|
|
if userInfo.Email == "" {
|
|
writeError(w, http.StatusUnauthorized, "provider did not return an email")
|
|
return
|
|
}
|
|
|
|
// Find or create user by email
|
|
user, orgID, err := s.findOrCreateOIDCUser(r, userInfo)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to provision user: "+err.Error())
|
|
return
|
|
}
|
|
|
|
userID := user.Id()
|
|
role := user.Role
|
|
|
|
accessToken, err := s.generateJWT(userID, orgID, role)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
|
return
|
|
}
|
|
refreshToken, err := s.generateRefreshToken(userID, orgID, role)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate refresh token")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"user_id": userID,
|
|
"org_id": orgID,
|
|
"role": role,
|
|
"email": userInfo.Email,
|
|
"access_token": accessToken,
|
|
"refresh_token": refreshToken,
|
|
})
|
|
}
|
|
|
|
// fetchUserInfo calls the OIDC provider's userinfo endpoint to validate the token.
|
|
//
|
|
// The path is /v1/iam/oauth/userinfo, not /oauth/userinfo. The latter is served
|
|
// by the SPA shell, so it answers 200 with text/html and the JSON decode fails
|
|
// on '<' — which surfaced as a 401 on a token that was in fact valid, and made
|
|
// every custody write path unreachable in production. The allowlist keys on the
|
|
// issuer origin (https://lux.id, https://hanzo.id), so the /v1/iam prefix cannot
|
|
// be supplied by the caller and belongs here.
|
|
func fetchUserInfo(providerURL, accessToken string) (*oidcUserInfo, error) {
|
|
userinfoURL := strings.TrimRight(providerURL, "/") + "/v1/iam/oauth/userinfo"
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
req, err := http.NewRequest("GET", userinfoURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("userinfo request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("userinfo returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var info oidcUserInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
return nil, fmt.Errorf("failed to decode userinfo: %w", err)
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// findOrCreateOIDCUser looks up a user by email or auto-provisions them.
|
|
func (s *Server) findOrCreateOIDCUser(r *http.Request, info *oidcUserInfo) (*db.User, string, error) {
|
|
// Try to find existing user
|
|
existing, err := orm.TypedQuery[db.User](s.db.ORM).
|
|
Filter("email=", info.Email).
|
|
First()
|
|
if err == nil && existing != nil {
|
|
return existing, existing.OrgID, nil
|
|
}
|
|
|
|
// Auto-provision: create org + user
|
|
displayName := info.Name
|
|
if displayName == "" {
|
|
displayName = info.DisplayName
|
|
}
|
|
if displayName == "" {
|
|
displayName = info.PreferredUsername
|
|
}
|
|
if displayName == "" {
|
|
displayName = strings.Split(info.Email, "@")[0]
|
|
}
|
|
|
|
orgName := displayName + "'s Org"
|
|
slug := slugify(orgName)
|
|
|
|
var orgID, userID string
|
|
err = s.db.ORM.RunInTransaction(r.Context(), func(tx orm.DB) error {
|
|
org := orm.New[db.Organization](tx)
|
|
org.Name = orgName
|
|
org.Slug = slug
|
|
if err := org.Create(); err != nil {
|
|
return err
|
|
}
|
|
orgID = org.Id()
|
|
|
|
user := orm.New[db.User](tx)
|
|
user.OrgID = orgID
|
|
user.Email = info.Email
|
|
user.PasswordHash = "" // OIDC users don't have local passwords
|
|
user.Role = "owner"
|
|
if err := user.Create(); err != nil {
|
|
return err
|
|
}
|
|
userID = user.Id()
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("provision failed: %w", err)
|
|
}
|
|
|
|
user, err := orm.Get[db.User](s.db.ORM, userID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return user, orgID, nil
|
|
}
|
|
|
|
func (s *Server) isAllowedOIDCIssuer(url string) bool {
|
|
normalized := strings.TrimRight(url, "/")
|
|
for _, issuer := range s.oidcIssuers {
|
|
if strings.TrimRight(issuer, "/") == normalized {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|