Files
base/plugins/platform/iam_embedded_dev.go
zeekay ec4a8a8a99 embedded IAM: discovery + OTP + 2FA scaffolding + dev autologin
ONE source of truth for enabled auth methods. The server emits the
live list, the SPA renders exactly that. No env mismatch, no
client-side feature guessing. Decomplected primary auth (password,
social, wallet, email-OTP) from secondary 2FA so flows compose.

Endpoints:
  GET  /v1/iam/auth/methods            live list of enabled methods
  POST /v1/iam/oauth/otp/start         send email/SMS OTP, return challenge_id
  POST /v1/iam/oauth/otp/verify        verify code, mint OIDC code
  GET  /v1/iam/oauth/dev-login         dev-only autologin (root user)

Each provider is opt-in via env. Wallet first in the button order
per UX directive (Connect Wallet → social → OTP → password).

  WALLET_LOGIN_ENABLED=true                       Connect Wallet
  GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET          Continue with Google
  GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET          Continue with GitHub
  APPLE_CLIENT_ID + APPLE_CLIENT_SECRET            Continue with Apple
  SMTP_HOST (+ SMTP_PORT/USER/PASS/FROM)           Email code
  TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN + ...     SMS code
  IAM_REQUIRE_2FA=true                             second-factor enforced
  IAM_DEV_AUTOLOGIN=true                           "Sign in as root (dev)"

Zero-config (no env): only email+password renders. Verified via curl
+ Playwright at three configs (all-enabled, none, dev).

OTPProvider is a 2-method interface (Channel, Send). Twilio is the
default SMS adapter; SMTP is the default email adapter. Swap by
implementing the interface — same pattern as IDVProvider from the
onboarding sketch.

The dev autologin endpoint is strictly gated: requires
IAM_DEV_AUTOLOGIN=true AND a seeded EMBEDDED_IAM_ROOT_EMAIL. Without
both it returns 403. The button only renders in the template when
the env is set, so prod can't accidentally enable it via UI alone.
2026-05-16 11:28:25 -07:00

92 lines
2.6 KiB
Go

package platform
// Dev-only convenience: an "Sign in as root (dev)" button that
// auto-authenticates as the seeded embedded-IAM root user without
// prompting for a password. Strictly opt-in via env so it can't
// accidentally ship to prod:
//
// IAM_DEV_AUTOLOGIN=true
//
// AND the bootstrap root must exist (EMBEDDED_IAM_ROOT_EMAIL must
// have a matching row in _iam_users). Without both, the endpoint
// returns 403 — guard rails for dev-only behavior.
import (
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/router"
)
func devAutoLoginEnabled() bool {
return os.Getenv("IAM_DEV_AUTOLOGIN") == "true"
}
func (p *plugin) registerEmbeddedDevRoutes(r *router.Router[*core.RequestEvent]) {
if p.embeddedIAM == nil || !devAutoLoginEnabled() {
return
}
r.GET(embeddedIAMMount+"/oauth/dev-login", p.handleDevAutoLogin)
}
// handleDevAutoLogin mints an OIDC code for the bootstrap root user
// and redirects to the caller's redirect_uri — equivalent to a
// successful password login, no credentials prompt.
func (p *plugin) handleDevAutoLogin(e *core.RequestEvent) error {
if !devAutoLoginEnabled() {
return e.ForbiddenError("dev autologin disabled", nil)
}
q := e.Request.URL.Query()
clientID := q.Get("client_id")
redirectURI := q.Get("redirect_uri")
state := q.Get("state")
if clientID == "" || redirectURI == "" {
return e.BadRequestError("client_id and redirect_uri required", nil)
}
rootEmail := strings.TrimSpace(strings.ToLower(os.Getenv("EMBEDDED_IAM_ROOT_EMAIL")))
if rootEmail == "" {
return e.ForbiddenError("EMBEDDED_IAM_ROOT_EMAIL not set", nil)
}
user, err := p.app.FindFirstRecordByData(collectionIAMUsers, "email", rootEmail)
if err != nil || user == nil {
return e.ForbiddenError("root user not seeded", err)
}
code, err := generateOpaqueCode()
if err != nil {
return e.InternalServerError("issue code", err)
}
p.embeddedIAM.mu.Lock()
p.embeddedIAM.codes[code] = &pendingCode{
userID: user.Id,
email: user.GetString("email"),
name: user.GetString("name"),
clientID: clientID,
redirectURI: redirectURI,
expires: time.Now().Add(embeddedIAMCodeTTL),
}
p.embeddedIAM.evictExpiredCodesLocked()
p.embeddedIAM.mu.Unlock()
u, err := url.Parse(redirectURI)
if err != nil {
return e.BadRequestError("invalid redirect_uri", err)
}
qp := u.Query()
qp.Set("code", code)
if state != "" {
qp.Set("state", state)
}
u.RawQuery = qp.Encode()
e.Response.Header().Set("Location", u.String())
e.Response.WriteHeader(http.StatusFound)
return nil
}