mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
998 lines
37 KiB
Go
998 lines
37 KiB
Go
// Package platform — IAM_MODE=embedded in-process OIDC provider.
|
|
//
|
|
// When IAM_MODE=embedded is set, Base does NOT proxy /v1/iam to an
|
|
// external IAM. Instead it hosts a minimal OIDC provider in-process,
|
|
// sufficient for @hanzo/iam/browser PKCE clients to sign in, validate
|
|
// JWTs via JWKS, and read userinfo.
|
|
//
|
|
// Scope is intentionally tiny — single-tenant, single-domain. No
|
|
// federation, no MFA, no refresh tokens, no password reset. A user
|
|
// either signs in with email+password (bcrypt cost 12) or they don't.
|
|
// For everything richer (orgs, providers, MFA, audit trail), boot
|
|
// against an external Hanzo IAM at IAM_ENDPOINT.
|
|
//
|
|
// On-disk artifacts:
|
|
//
|
|
// - ${DataDir}/iam.key — RSA-2048 private key (PEM). Generated on
|
|
// first boot. The public half is exposed at JWKS. Lose this and
|
|
// all outstanding JWTs become unverifiable.
|
|
// - _iam_users collection — email, password (bcrypt), name.
|
|
//
|
|
// Endpoints (mounted under /v1/iam):
|
|
//
|
|
// GET /.well-known/openid-configuration
|
|
// GET /.well-known/jwks
|
|
// GET /oauth/authorize?client_id&redirect_uri&state&scope&response_type=code
|
|
// POST /oauth/login (form: email, password, state)
|
|
// POST /oauth/token (form: grant_type=authorization_code, code, redirect_uri, client_id)
|
|
// GET /oauth/userinfo (Authorization: Bearer ...)
|
|
//
|
|
// Bootstrap: set EMBEDDED_IAM_ROOT_EMAIL and EMBEDDED_IAM_ROOT_PASSWORD
|
|
// on first boot to seed the root user. Subsequent boots are no-ops if
|
|
// _iam_users already has rows.
|
|
|
|
package platform
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"html/template"
|
|
"math/big"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/router"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
// collectionIAMUsers stores embedded-IAM identities. System collection.
|
|
collectionIAMUsers = "_iam_users"
|
|
|
|
// embeddedIAMMount is the URL prefix all embedded handlers live under.
|
|
// It matches the proxy mount so @hanzo/iam/browser sees the same
|
|
// surface regardless of mode. One path: /v1/iam. Not Casdoor's /api.
|
|
embeddedIAMMount = "/v1/iam"
|
|
|
|
// embeddedIAMKeyID identifies the in-process signing key in JWKS.
|
|
embeddedIAMKeyID = "embedded-1"
|
|
|
|
// embeddedIAMCodeTTL is how long an authorization_code stays valid.
|
|
embeddedIAMCodeTTL = 5 * time.Minute
|
|
|
|
// embeddedIAMTokenTTL is how long an issued JWT lives.
|
|
embeddedIAMTokenTTL = 1 * time.Hour
|
|
|
|
// embeddedIAMBcryptCost is the work factor for password hashing.
|
|
embeddedIAMBcryptCost = 12
|
|
)
|
|
|
|
// IsEmbeddedIAM returns true when IAM_MODE=embedded. The platform
|
|
// plugin uses this to skip external endpoint validation and mount
|
|
// in-process handlers in place of the reverse proxy.
|
|
func IsEmbeddedIAM() bool {
|
|
return strings.EqualFold(os.Getenv("IAM_MODE"), "embedded")
|
|
}
|
|
|
|
// embeddedIAM owns the in-process OIDC state: signing key, pending
|
|
// authorization codes, and the bcrypt-verified user lookup. The
|
|
// _iam_users collection holds the durable user record; this struct
|
|
// holds the transient cryptographic + code state.
|
|
type embeddedIAM struct {
|
|
app core.App
|
|
signer *rsa.PrivateKey
|
|
kid string
|
|
|
|
mu sync.Mutex
|
|
codes map[string]*pendingCode // code -> pending grant
|
|
}
|
|
|
|
// pendingCode is the short-lived authorization grant held between
|
|
// /oauth/login and /oauth/token. The client provides `state` to
|
|
// /oauth/authorize, we round-trip it through the login form, and the
|
|
// resulting code is keyed against expiry + the intended client_id.
|
|
type pendingCode struct {
|
|
userID string
|
|
email string
|
|
name string
|
|
clientID string
|
|
redirectURI string
|
|
expires time.Time
|
|
}
|
|
|
|
// newEmbeddedIAM loads or generates the RSA signing key from
|
|
// ${DataDir}/iam.key and returns an initialized provider.
|
|
//
|
|
// The key is generated once on first boot and persisted as PEM. Losing
|
|
// it invalidates every outstanding JWT; backing it up is the
|
|
// operator's job (the file lives next to the SQLite database, so any
|
|
// DataDir backup covers it).
|
|
func newEmbeddedIAM(app core.App) (*embeddedIAM, error) {
|
|
keyPath := filepath.Join(app.DataDir(), "iam.key")
|
|
|
|
key, err := loadOrGenerateRSAKey(keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("embedded iam: load signing key: %w", err)
|
|
}
|
|
|
|
return &embeddedIAM{
|
|
app: app,
|
|
signer: key,
|
|
kid: embeddedIAMKeyID,
|
|
codes: make(map[string]*pendingCode),
|
|
}, nil
|
|
}
|
|
|
|
// loadOrGenerateRSAKey reads the PEM-encoded RSA key at path, or
|
|
// generates and persists a fresh RSA-2048 key if the file is missing.
|
|
//
|
|
// Filesystem permissions are tightened to 0600 — only the daemon user
|
|
// should be able to read the signing key.
|
|
func loadOrGenerateRSAKey(path string) (*rsa.PrivateKey, error) {
|
|
if buf, err := os.ReadFile(path); err == nil {
|
|
block, _ := pem.Decode(buf)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("decode pem from %s: empty or malformed", path)
|
|
}
|
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
// PKCS#8 fallback for keys generated by external tooling.
|
|
anyKey, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err2 != nil {
|
|
return nil, fmt.Errorf("parse rsa key (tried PKCS1 and PKCS8): %w / %w", err, err2)
|
|
}
|
|
rsaKey, ok := anyKey.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, fmt.Errorf("key at %s is not RSA", path)
|
|
}
|
|
return rsaKey, nil
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// Generate fresh key.
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return nil, fmt.Errorf("create key dir: %w", err)
|
|
}
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate rsa key: %w", err)
|
|
}
|
|
pemBytes := pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
|
})
|
|
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
|
return nil, fmt.Errorf("persist rsa key: %w", err)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// ensureUsersCollection creates the _iam_users system collection if
|
|
// it does not already exist. Idempotent.
|
|
func (p *plugin) ensureUsersCollection() error {
|
|
return EnsureIAMUsersCollection(p.app)
|
|
}
|
|
|
|
// EnsureIAMUsersCollection creates the _iam_users system collection if
|
|
// missing. Idempotent; safe to call from CLI subcommands that boot
|
|
// the app without going through the platform OnBootstrap path.
|
|
func EnsureIAMUsersCollection(app core.App) error {
|
|
if _, err := app.FindCollectionByNameOrId(collectionIAMUsers); err == nil {
|
|
return nil
|
|
}
|
|
|
|
c := core.NewBaseCollection(collectionIAMUsers)
|
|
c.System = true
|
|
c.Fields.Add(
|
|
&core.TextField{Name: "email", Required: true, Min: 3, Max: 200},
|
|
&core.TextField{Name: "password", Required: true, Min: 1, Max: 200}, // bcrypt hash, not plaintext
|
|
&core.TextField{Name: "name", Required: false, Max: 200},
|
|
&core.AutodateField{Name: "created", OnCreate: true},
|
|
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
|
)
|
|
|
|
app.Logger().Info("creating embedded IAM collection", "name", collectionIAMUsers)
|
|
return app.Save(c)
|
|
}
|
|
|
|
// bootstrapRootUser seeds the root user from EMBEDDED_IAM_ROOT_EMAIL +
|
|
// EMBEDDED_IAM_ROOT_PASSWORD on first boot. Idempotent: if _iam_users
|
|
// has any rows, this is a no-op.
|
|
func (p *plugin) bootstrapRootUser() error {
|
|
email := strings.TrimSpace(os.Getenv("EMBEDDED_IAM_ROOT_EMAIL"))
|
|
password := os.Getenv("EMBEDDED_IAM_ROOT_PASSWORD")
|
|
if email == "" || password == "" {
|
|
return nil // operator chose CLI path or already seeded
|
|
}
|
|
|
|
existing, _ := p.app.FindRecordsByFilter(collectionIAMUsers, "id != ''", "", 1, 0, nil)
|
|
if len(existing) > 0 {
|
|
return nil
|
|
}
|
|
|
|
if _, err := p.createIAMUser(email, password, "Root"); err != nil {
|
|
return fmt.Errorf("bootstrap root user %s: %w", email, err)
|
|
}
|
|
|
|
// Mirror the root identity into _superusers so admin endpoints
|
|
// (collections, settings, logs, backups) authorize after the OIDC
|
|
// round-trip. The collection has no password field in IAM-native
|
|
// mode — IAM owns the credential. Email is the only join key.
|
|
// Idempotent: skip if a row already exists.
|
|
if su, _ := p.app.FindAuthRecordByEmail(core.CollectionNameSuperusers, email); su == nil {
|
|
col, err := p.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
|
if err != nil {
|
|
return fmt.Errorf("bootstrap root superuser: find _superusers: %w", err)
|
|
}
|
|
su := core.NewRecord(col)
|
|
su.SetEmail(email)
|
|
if err := p.app.Save(su); err != nil {
|
|
return fmt.Errorf("bootstrap root superuser: save: %w", err)
|
|
}
|
|
}
|
|
|
|
p.app.Logger().Info("embedded iam: bootstrapped root user + superuser", "email", email)
|
|
return nil
|
|
}
|
|
|
|
// createIAMUser inserts a user with a bcrypt-hashed password into the
|
|
// _iam_users collection. Used by bootstrap and the CLI subcommand.
|
|
func (p *plugin) createIAMUser(email, password, name string) (*core.Record, error) {
|
|
return CreateEmbeddedIAMUser(p.app, email, password, name)
|
|
}
|
|
|
|
// CreateEmbeddedIAMUser is the public entry point that the iam-user
|
|
// CLI subcommand uses to seed a user against the _iam_users
|
|
// collection. The collection must already exist (it does once the
|
|
// platform plugin has bootstrapped against IAM_MODE=embedded). The
|
|
// password is bcrypted at cost 12 before persisting; the plaintext
|
|
// never reaches disk.
|
|
func CreateEmbeddedIAMUser(app core.App, email, password, name string) (*core.Record, error) {
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
if email == "" || password == "" {
|
|
return nil, fmt.Errorf("email and password are required")
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), embeddedIAMBcryptCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
col, err := app.FindCollectionByNameOrId(collectionIAMUsers)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("collection %s not found (is IAM_MODE=embedded?): %w", collectionIAMUsers, err)
|
|
}
|
|
|
|
rec := core.NewRecord(col)
|
|
rec.Set("email", email)
|
|
rec.Set("password", string(hash))
|
|
rec.Set("name", name)
|
|
if err := app.Save(rec); err != nil {
|
|
return nil, fmt.Errorf("save user: %w", err)
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// authenticateIAMUser verifies email+password against _iam_users and
|
|
// returns the matching record. Errors are intentionally generic so we
|
|
// don't leak which half (email vs password) was wrong.
|
|
func (p *plugin) authenticateIAMUser(email, password string) (*core.Record, error) {
|
|
return AuthenticateEmbeddedIAMUser(p.app, email, password)
|
|
}
|
|
|
|
// AuthenticateEmbeddedIAMUser is the public verifier used by the OIDC
|
|
// /oauth/login handler AND by tests. It looks up the user by email
|
|
// (case-insensitive), then bcrypt-compares the supplied password
|
|
// against the stored hash. Returns a generic "invalid credentials"
|
|
// error on any failure to avoid leaking which half was wrong.
|
|
func AuthenticateEmbeddedIAMUser(app core.App, email, password string) (*core.Record, error) {
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
rec, err := app.FindFirstRecordByData(collectionIAMUsers, "email", email)
|
|
if err != nil || rec == nil {
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
hash := rec.GetString("password")
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Route registration
|
|
// --------------------------------------------------------------------------
|
|
|
|
// registerEmbeddedIAM mounts the in-process OIDC endpoints. Called by
|
|
// platform.Register when IAM_MODE=embedded. It replaces (not augments)
|
|
// the reverse-proxy mount; clients see /v1/iam/* either way.
|
|
func (p *plugin) registerEmbeddedIAM(r *router.Router[*core.RequestEvent]) {
|
|
if p.embeddedIAM == nil {
|
|
return
|
|
}
|
|
|
|
r.GET(embeddedIAMMount+"/.well-known/openid-configuration", p.handleEmbeddedDiscovery)
|
|
r.GET(embeddedIAMMount+"/.well-known/jwks", p.handleEmbeddedJWKS)
|
|
r.GET(embeddedIAMMount+"/oauth/authorize", p.handleEmbeddedAuthorize)
|
|
r.POST(embeddedIAMMount+"/oauth/login", p.handleEmbeddedLogin)
|
|
r.POST(embeddedIAMMount+"/oauth/token", p.handleEmbeddedToken)
|
|
r.GET(embeddedIAMMount+"/oauth/userinfo", p.handleEmbeddedUserinfo)
|
|
|
|
// Social (Google / GitHub / Apple) + wallet (SIWE). Each provider
|
|
// is opt-in via env, so this is a no-op if nothing is configured.
|
|
p.registerEmbeddedSocialRoutes(r)
|
|
|
|
// OTP (email + SMS) + /v1/iam/auth/methods discovery. SPAs read
|
|
// the methods endpoint to render only the buttons whose backends
|
|
// are actually wired — one source of truth, server-emitted.
|
|
p.registerEmbeddedOTPRoutes(r)
|
|
|
|
// Dev convenience: "Sign in as root" button. Strictly opt-in via
|
|
// IAM_DEV_AUTOLOGIN=true so prod can't accidentally enable it.
|
|
p.registerEmbeddedDevRoutes(r)
|
|
|
|
// IDV proxy mount lives at /v1/idv (sibling, not nested) and is
|
|
// registered unconditionally in platform.go — not here. Keeping
|
|
// the call out of this embedded-only block so /v1/idv/* works
|
|
// in external IAM mode too.
|
|
}
|
|
|
|
// requestOrigin returns the scheme://host the embedded IAM should
|
|
// advertise in OIDC discovery + JWT iss. Honors X-Forwarded-Proto so
|
|
// the issuer matches the public URL behind an ingress.
|
|
func requestOrigin(r *http.Request) string {
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
if v := r.Header.Get("X-Forwarded-Proto"); v != "" {
|
|
scheme = v
|
|
}
|
|
return scheme + "://" + r.Host
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Discovery + JWKS
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (p *plugin) handleEmbeddedDiscovery(e *core.RequestEvent) error {
|
|
origin := requestOrigin(e.Request) + embeddedIAMMount
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"issuer": origin,
|
|
"authorization_endpoint": origin + "/oauth/authorize",
|
|
"token_endpoint": origin + "/oauth/token",
|
|
"userinfo_endpoint": origin + "/oauth/userinfo",
|
|
"jwks_uri": origin + "/.well-known/jwks",
|
|
"response_types_supported": []string{"code"},
|
|
"grant_types_supported": []string{"authorization_code"},
|
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
|
"subject_types_supported": []string{"public"},
|
|
"scopes_supported": []string{"openid", "email", "profile"},
|
|
"token_endpoint_auth_methods_supported": []string{"client_secret_post", "none"},
|
|
})
|
|
}
|
|
|
|
func (p *plugin) handleEmbeddedJWKS(e *core.RequestEvent) error {
|
|
pub := &p.embeddedIAM.signer.PublicKey
|
|
jwk := map[string]any{
|
|
"kty": "RSA",
|
|
"kid": p.embeddedIAM.kid,
|
|
"alg": "RS256",
|
|
"use": "sig",
|
|
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
|
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"keys": []map[string]any{jwk},
|
|
})
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Authorize + login
|
|
// --------------------------------------------------------------------------
|
|
|
|
// authorizeForm renders the embedded IAM login page. Self-contained:
|
|
// no JS, no external fonts, no CSS framework. Dark theme + canonical
|
|
// block-H mark + Hanzo Red CTA. Brand name + accent are env-driven
|
|
// (BRAND_NAME / BRAND_ACCENT) so white-label deployments override
|
|
// without touching the binary. Error state re-renders this template
|
|
// with `Error` set so failed credentials don't drop to a generic
|
|
// JSON 401.
|
|
var authorizeForm = template.Must(template.New("login").Parse(authorizeFormHTML))
|
|
|
|
// Monochrome. Black + white only — no accent colour. The Hanzo brand
|
|
// is brand-neutral so white-label deployments can drop in their own
|
|
// mark/name without restyling. The only knobs are BRAND_NAME and
|
|
// BRAND_SUBTITLE; everything visual is grayscale.
|
|
const authorizeFormHTML = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Sign in to {{.BrandName}}</title>
|
|
<style>
|
|
*{box-sizing:border-box}
|
|
html,body{margin:0;padding:0;height:100%}
|
|
body{
|
|
background:#000;color:#f5f5f5;
|
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;
|
|
-webkit-font-smoothing:antialiased;
|
|
display:flex;align-items:center;justify-content:center;
|
|
padding:24px;
|
|
}
|
|
.card{
|
|
width:100%;max-width:380px;background:#0a0a0a;border:1px solid #262626;
|
|
border-radius:16px;padding:32px 28px 28px;
|
|
box-shadow:0 24px 48px -20px rgba(0,0,0,.7),0 2px 6px rgba(0,0,0,.5);
|
|
}
|
|
.brand{display:flex;align-items:center;gap:12px;margin-bottom:24px}
|
|
.brand .mark{
|
|
width:36px;height:36px;background:#000;border-radius:8px;
|
|
display:flex;align-items:center;justify-content:center;flex-shrink:0;
|
|
border:1px solid #262626;
|
|
}
|
|
.brand .mark svg{width:26px;height:26px}
|
|
.brand .title{font-size:14px;font-weight:600;letter-spacing:-.01em;color:#f5f5f5;line-height:1.2}
|
|
.brand .sub{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#737373;margin-top:2px}
|
|
h1{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0 0 6px;color:#f5f5f5}
|
|
.hint{font-size:13px;color:#a3a3a3;margin:0 0 24px;line-height:1.5}
|
|
.providers{display:flex;flex-direction:column;gap:8px;margin-bottom:18px}
|
|
.providers a, .providers button{
|
|
display:flex;align-items:center;justify-content:center;gap:10px;
|
|
width:100%;padding:10px 14px;font-size:13px;font-weight:500;
|
|
background:#0a0a0a;color:#f5f5f5;border:1px solid #262626;border-radius:8px;
|
|
text-decoration:none;cursor:pointer;font-family:inherit;
|
|
transition:background .12s,border-color .12s;
|
|
}
|
|
.providers a:hover,.providers button:hover{background:#1a1a1a;border-color:#404040}
|
|
.providers svg{width:16px;height:16px;flex-shrink:0}
|
|
.divider{
|
|
display:flex;align-items:center;gap:10px;margin:18px 0;
|
|
font-size:11px;color:#737373;text-transform:uppercase;letter-spacing:.1em;
|
|
}
|
|
.divider:before,.divider:after{content:"";flex:1;height:1px;background:#262626}
|
|
.field{margin-bottom:14px}
|
|
.field label{display:block;font-size:12px;font-weight:500;color:#d4d4d4;margin-bottom:6px}
|
|
.field input{
|
|
width:100%;padding:10px 12px;font-size:14px;line-height:1.4;
|
|
background:#000;color:#f5f5f5;border:1px solid #262626;border-radius:8px;
|
|
outline:none;transition:border-color .12s,box-shadow .12s;
|
|
font-family:inherit;
|
|
}
|
|
.field input:focus{border-color:#737373;box-shadow:0 0 0 3px rgba(255,255,255,.06)}
|
|
.field input::placeholder{color:#525252}
|
|
button[type=submit]{
|
|
width:100%;margin-top:8px;padding:11px 16px;font-size:14px;font-weight:600;
|
|
background:#f5f5f5;color:#000;border:0;border-radius:8px;cursor:pointer;
|
|
transition:background .12s,transform .04s;
|
|
font-family:inherit;
|
|
}
|
|
button[type=submit]:hover{background:#ffffff}
|
|
button[type=submit]:active{transform:translateY(1px);background:#e5e5e5}
|
|
.error{
|
|
margin-bottom:16px;padding:10px 12px;font-size:13px;
|
|
background:#1a1a1a;color:#f5f5f5;
|
|
border:1px solid #404040;border-radius:8px;
|
|
}
|
|
.dev-banner{
|
|
margin-bottom:16px;padding:8px 10px;font-size:11px;line-height:1.3;
|
|
background:#1f1611;color:#fbbf24;border:1px solid #7c2d12;border-radius:8px;
|
|
display:flex;flex-direction:column;gap:6px;
|
|
}
|
|
.dev-banner .label{
|
|
text-transform:uppercase;letter-spacing:.12em;font-weight:600;color:#fb923c;
|
|
}
|
|
.dev-banner a{
|
|
display:flex;align-items:center;justify-content:center;
|
|
padding:8px 12px;font-size:12px;font-weight:600;
|
|
background:#7c2d12;color:#fff;border:0;border-radius:6px;
|
|
text-decoration:none;cursor:pointer;
|
|
}
|
|
.dev-banner a:hover{background:#9a3412}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="brand">
|
|
<span class="mark" aria-hidden="true">
|
|
<svg viewBox="0 0 67 67" fill="#fff" aria-hidden="true">
|
|
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
|
|
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
|
|
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
|
|
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
|
|
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
|
|
</svg>
|
|
</span>
|
|
<div>
|
|
<div class="title">{{.BrandName}}</div>
|
|
<div class="sub">{{.BrandSubtitle}}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h1>Sign in</h1>
|
|
<p class="hint">Continue with your {{.BrandName}} account.</p>
|
|
|
|
{{if .DevAutoLogin}}
|
|
<div class="dev-banner">
|
|
<span class="label">Dev mode</span>
|
|
<a href="/v1/iam/oauth/dev-login?client_id={{.ClientID}}&redirect_uri={{.RedirectURI}}&state={{.State}}">
|
|
Sign in as root (dev)
|
|
</a>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .Error}}<div class="error" role="alert">{{.Error}}</div>{{end}}
|
|
|
|
{{if or .Providers .WalletEnabled .EmailOTPEnabled .SMSOTPEnabled}}
|
|
<div class="providers">
|
|
{{if .WalletEnabled}}
|
|
<button type="button" id="connect-wallet">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M19 7H5c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm0 10H5V9h14v8zm-3-4c0-.83.67-1.5 1.5-1.5S19 12.17 19 13s-.67 1.5-1.5 1.5S16 13.83 16 13zM3 6V5c0-1.1.9-2 2-2h12v2H5v1H3z"/></svg>
|
|
Connect Wallet
|
|
</button>
|
|
{{end}}
|
|
{{range .Providers}}
|
|
<a href="/v1/iam/oauth/social/{{.Key}}?client_id={{$.ClientID}}&redirect_uri={{$.RedirectURI}}&state={{$.State}}">
|
|
{{if eq .Key "google"}}<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M21.35 11.1h-9.17v2.93h5.27c-.23 1.24-1.6 3.64-5.27 3.64-3.17 0-5.76-2.62-5.76-5.86s2.59-5.86 5.76-5.86c1.8 0 3.01.77 3.7 1.43l2.53-2.43C16.74 3.36 14.7 2.4 12.18 2.4 6.96 2.4 2.78 6.58 2.78 11.8s4.18 9.4 9.4 9.4c5.43 0 9.02-3.81 9.02-9.18 0-.62-.07-1.09-.17-1.55l-7.85-.37z"/></svg>{{end}}
|
|
{{if eq .Key "github"}}<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.5.5.09.68-.22.68-.48v-1.69c-2.78.6-3.37-1.34-3.37-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.1.39-1.99 1.03-2.69-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.69 0 3.84-2.34 4.68-4.57 4.93.36.31.69.92.69 1.85V21c0 .27.18.58.69.48A10.02 10.02 0 0022 12c0-5.52-4.48-10-10-10z"/></svg>{{end}}
|
|
{{if eq .Key "apple"}}<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/></svg>{{end}}
|
|
Continue with {{.Name}}
|
|
</a>
|
|
{{end}}
|
|
{{if .EmailOTPEnabled}}
|
|
<button type="button" id="email-otp" data-channel="email">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
|
Email me a code
|
|
</button>
|
|
{{end}}
|
|
{{if .SMSOTPEnabled}}
|
|
<button type="button" id="sms-otp" data-channel="sms">
|
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM9 11H7V9h2v2zm4 0h-2V9h2v2zm4 0h-2V9h2v2z"/></svg>
|
|
Text me a code
|
|
</button>
|
|
{{end}}
|
|
</div>
|
|
<div class="divider">or continue with password</div>
|
|
{{end}}
|
|
|
|
<form method="POST" action="/v1/iam/oauth/login" novalidate>
|
|
<input type="hidden" name="client_id" value="{{.ClientID}}">
|
|
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
|
|
<input type="hidden" name="state" value="{{.State}}">
|
|
<div class="field">
|
|
<label for="email">Email</label>
|
|
<input id="email" type="email" name="email" required autocomplete="username"
|
|
placeholder="[email protected]" autofocus value="{{.Email}}">
|
|
</div>
|
|
<div class="field">
|
|
<label for="password">Password</label>
|
|
<input id="password" type="password" name="password" required autocomplete="current-password">
|
|
</div>
|
|
<button type="submit">Sign in</button>
|
|
</form>
|
|
</div>
|
|
|
|
{{if .WalletEnabled}}
|
|
<script>
|
|
document.getElementById('connect-wallet').addEventListener('click', async () => {
|
|
if (!window.ethereum) { alert('No browser wallet detected. Install MetaMask or similar.'); return }
|
|
try {
|
|
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' })
|
|
const address = accounts[0]
|
|
const challResp = await fetch('/v1/iam/oauth/wallet/challenge', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ address, client_id: {{.ClientIDJSON}}, redirect_uri: {{.RedirectURIJSON}}, state: {{.StateJSON}} })
|
|
})
|
|
if (!challResp.ok) { alert('Wallet challenge failed: ' + (await challResp.text())); return }
|
|
const { nonce, message } = await challResp.json()
|
|
const signature = await window.ethereum.request({ method: 'personal_sign', params: [message, address] })
|
|
const verifyResp = await fetch('/v1/iam/oauth/wallet/verify', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ address, message, signature, nonce })
|
|
})
|
|
if (!verifyResp.ok) { alert('Verify failed: ' + (await verifyResp.text())); return }
|
|
const { redirect } = await verifyResp.json()
|
|
window.location.href = redirect
|
|
} catch (e) { alert(String(e && e.message || e)) }
|
|
})
|
|
</script>
|
|
{{end}}
|
|
|
|
{{if or .EmailOTPEnabled .SMSOTPEnabled}}
|
|
<script>
|
|
async function startOTP(channel, promptLabel) {
|
|
const dest = window.prompt(promptLabel)
|
|
if (!dest) return
|
|
try {
|
|
const startResp = await fetch('/v1/iam/oauth/otp/start', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ channel, destination: dest, client_id: {{.ClientIDJSON}}, redirect_uri: {{.RedirectURIJSON}}, state: {{.StateJSON}} })
|
|
})
|
|
if (!startResp.ok) { alert('Failed to send code: ' + (await startResp.text())); return }
|
|
const { challenge_id, dest_hint } = await startResp.json()
|
|
const code = window.prompt('Code sent to ' + dest_hint + '. Enter the 6-digit code:')
|
|
if (!code) return
|
|
const verifyResp = await fetch('/v1/iam/oauth/otp/verify', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ challenge_id, code })
|
|
})
|
|
if (!verifyResp.ok) { alert('Invalid code: ' + (await verifyResp.text())); return }
|
|
const { redirect } = await verifyResp.json()
|
|
window.location.href = redirect
|
|
} catch (e) { alert(String(e && e.message || e)) }
|
|
}
|
|
{{if .EmailOTPEnabled}}document.getElementById('email-otp').addEventListener('click', () => startOTP('email', 'Email address:')){{end}}
|
|
{{if .SMSOTPEnabled}}document.getElementById('sms-otp').addEventListener('click', () => startOTP('sms', 'Phone number (E.164, e.g. +14155550100):')){{end}}
|
|
</script>
|
|
{{end}}
|
|
</body>
|
|
</html>`
|
|
|
|
// brandFromEnv returns the brand name + subtitle for the embedded
|
|
// login template. Defaults match Hanzo. Override at deploy via
|
|
// BRAND_NAME / BRAND_SUBTITLE. The visual surface is intentionally
|
|
// monochrome — no accent colour — so white-label deployments don't
|
|
// need to restyle.
|
|
func brandFromEnv() (name, subtitle string) {
|
|
name = os.Getenv("BRAND_NAME")
|
|
if name == "" {
|
|
name = "Hanzo"
|
|
}
|
|
subtitle = os.Getenv("BRAND_SUBTITLE")
|
|
if subtitle == "" {
|
|
subtitle = "Identity"
|
|
}
|
|
return
|
|
}
|
|
|
|
// providerView is the per-button data the login template iterates over.
|
|
type providerView struct {
|
|
Key string
|
|
Name string
|
|
}
|
|
|
|
// authorizeView is the full data model for the login template.
|
|
type authorizeView struct {
|
|
ClientID string
|
|
RedirectURI string
|
|
State string
|
|
Email string
|
|
Error string
|
|
BrandName string
|
|
BrandSubtitle string
|
|
Providers []providerView
|
|
WalletEnabled bool
|
|
EmailOTPEnabled bool
|
|
SMSOTPEnabled bool
|
|
DevAutoLogin bool
|
|
ClientIDJSON template.JS
|
|
RedirectURIJSON template.JS
|
|
StateJSON template.JS
|
|
}
|
|
|
|
func jsString(s string) template.JS {
|
|
b, _ := json.Marshal(s)
|
|
return template.JS(b)
|
|
}
|
|
|
|
func (p *plugin) renderAuthorize(e *core.RequestEvent, clientID, redirectURI, state, email, errMsg string, status int) error {
|
|
name, subtitle := brandFromEnv()
|
|
|
|
var provs []providerView
|
|
for _, pr := range enabledSocialProviders() {
|
|
provs = append(provs, providerView{Key: pr.Key, Name: pr.Name})
|
|
}
|
|
|
|
view := authorizeView{
|
|
ClientID: clientID,
|
|
RedirectURI: redirectURI,
|
|
State: state,
|
|
Email: email,
|
|
Error: errMsg,
|
|
BrandName: name,
|
|
BrandSubtitle: subtitle,
|
|
Providers: provs,
|
|
WalletEnabled: walletLoginEnabled(),
|
|
EmailOTPEnabled: emailOTPEnabled(),
|
|
SMSOTPEnabled: smsOTPEnabled(),
|
|
DevAutoLogin: devAutoLoginEnabled(),
|
|
ClientIDJSON: jsString(clientID),
|
|
RedirectURIJSON: jsString(redirectURI),
|
|
StateJSON: jsString(state),
|
|
}
|
|
|
|
e.Response.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if status != 0 {
|
|
e.Response.WriteHeader(status)
|
|
}
|
|
return authorizeForm.Execute(e.Response, view)
|
|
}
|
|
|
|
func (p *plugin) handleEmbeddedAuthorize(e *core.RequestEvent) error {
|
|
q := e.Request.URL.Query()
|
|
clientID := q.Get("client_id")
|
|
redirectURI := q.Get("redirect_uri")
|
|
state := q.Get("state")
|
|
responseType := q.Get("response_type")
|
|
|
|
if responseType != "" && responseType != "code" {
|
|
return e.BadRequestError("unsupported response_type", nil)
|
|
}
|
|
if clientID == "" || redirectURI == "" {
|
|
return e.BadRequestError("client_id and redirect_uri are required", nil)
|
|
}
|
|
|
|
return p.renderAuthorize(e, clientID, redirectURI, state, "", "", 0)
|
|
}
|
|
|
|
func (p *plugin) handleEmbeddedLogin(e *core.RequestEvent) error {
|
|
if err := e.Request.ParseForm(); err != nil {
|
|
return e.BadRequestError("invalid form", err)
|
|
}
|
|
email := strings.TrimSpace(e.Request.FormValue("email"))
|
|
password := e.Request.FormValue("password")
|
|
clientID := e.Request.FormValue("client_id")
|
|
redirectURI := e.Request.FormValue("redirect_uri")
|
|
state := e.Request.FormValue("state")
|
|
|
|
if clientID == "" || redirectURI == "" {
|
|
return e.BadRequestError("client_id and redirect_uri are required", nil)
|
|
}
|
|
|
|
user, err := p.authenticateIAMUser(email, password)
|
|
if err != nil {
|
|
// Re-render the polished form with an inline error so the
|
|
// user lands on the same page rather than a generic JSON 401.
|
|
return p.renderAuthorize(e, clientID, redirectURI, state, email, "Invalid email or password.", http.StatusUnauthorized)
|
|
}
|
|
|
|
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)
|
|
}
|
|
q := u.Query()
|
|
q.Set("code", code)
|
|
if state != "" {
|
|
q.Set("state", state)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
e.Response.Header().Set("Location", u.String())
|
|
e.Response.WriteHeader(http.StatusFound)
|
|
return nil
|
|
}
|
|
|
|
// evictExpiredCodesLocked removes expired entries. Caller must hold mu.
|
|
func (e *embeddedIAM) evictExpiredCodesLocked() {
|
|
now := time.Now()
|
|
for c, pc := range e.codes {
|
|
if now.After(pc.expires) {
|
|
delete(e.codes, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// generateOpaqueCode returns a 32-byte URL-safe random string.
|
|
func generateOpaqueCode() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Token + userinfo
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (p *plugin) handleEmbeddedToken(e *core.RequestEvent) error {
|
|
if err := e.Request.ParseForm(); err != nil {
|
|
return e.BadRequestError("invalid form", err)
|
|
}
|
|
grantType := e.Request.FormValue("grant_type")
|
|
code := e.Request.FormValue("code")
|
|
clientID := e.Request.FormValue("client_id")
|
|
redirectURI := e.Request.FormValue("redirect_uri")
|
|
|
|
if grantType != "authorization_code" {
|
|
return e.BadRequestError("unsupported grant_type", nil)
|
|
}
|
|
if code == "" {
|
|
return e.BadRequestError("code is required", nil)
|
|
}
|
|
|
|
p.embeddedIAM.mu.Lock()
|
|
pc, ok := p.embeddedIAM.codes[code]
|
|
if ok {
|
|
delete(p.embeddedIAM.codes, code) // single-use
|
|
}
|
|
p.embeddedIAM.mu.Unlock()
|
|
|
|
if !ok || time.Now().After(pc.expires) {
|
|
return e.Error(http.StatusBadRequest, "invalid or expired code", nil)
|
|
}
|
|
if clientID != "" && clientID != pc.clientID {
|
|
return e.Error(http.StatusBadRequest, "client_id mismatch", nil)
|
|
}
|
|
if redirectURI != "" && redirectURI != pc.redirectURI {
|
|
return e.Error(http.StatusBadRequest, "redirect_uri mismatch", nil)
|
|
}
|
|
|
|
now := time.Now()
|
|
origin := requestOrigin(e.Request) + embeddedIAMMount
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
|
"iss": origin,
|
|
"sub": pc.userID,
|
|
"aud": pc.clientID,
|
|
"email": pc.email,
|
|
"name": pc.name,
|
|
"iat": now.Unix(),
|
|
"exp": now.Add(embeddedIAMTokenTTL).Unix(),
|
|
})
|
|
token.Header["kid"] = p.embeddedIAM.kid
|
|
|
|
signed, err := token.SignedString(p.embeddedIAM.signer)
|
|
if err != nil {
|
|
return e.InternalServerError("sign token", err)
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"access_token": signed,
|
|
"id_token": signed,
|
|
"token_type": "Bearer",
|
|
"expires_in": int(embeddedIAMTokenTTL / time.Second),
|
|
"scope": "openid email profile",
|
|
})
|
|
}
|
|
|
|
func (p *plugin) handleEmbeddedUserinfo(e *core.RequestEvent) error {
|
|
auth := e.Request.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|
return e.UnauthorizedError("missing bearer token", nil)
|
|
}
|
|
rawToken := strings.TrimPrefix(auth, "Bearer ")
|
|
|
|
parsed, err := jwt.Parse(rawToken, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
|
return nil, fmt.Errorf("unexpected alg %v", t.Header["alg"])
|
|
}
|
|
return &p.embeddedIAM.signer.PublicKey, nil
|
|
}, jwt.WithValidMethods([]string{"RS256"}))
|
|
if err != nil || !parsed.Valid {
|
|
return e.UnauthorizedError("invalid token", err)
|
|
}
|
|
|
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return e.UnauthorizedError("invalid claims", nil)
|
|
}
|
|
|
|
sub, _ := claims["sub"].(string)
|
|
if sub == "" {
|
|
return e.UnauthorizedError("missing sub", nil)
|
|
}
|
|
|
|
rec, err := p.app.FindRecordById(collectionIAMUsers, sub)
|
|
if err != nil || rec == nil {
|
|
return e.UnauthorizedError("user not found", err)
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"id": rec.Id,
|
|
"sub": rec.Id,
|
|
"email": rec.GetString("email"),
|
|
"name": rec.GetString("name"),
|
|
"email_verified": true,
|
|
})
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Auth middleware
|
|
// --------------------------------------------------------------------------
|
|
|
|
// embeddedIAMAuthMiddleware validates the Authorization bearer token
|
|
// against the in-process RSA signer. On success, it populates
|
|
// e.Auth with an ephemeral record from _iam_users so downstream
|
|
// middleware sees the standard IAM-authenticated request shape.
|
|
//
|
|
// Runs BEFORE the default loadAuthToken middleware. If the token
|
|
// isn't a valid embedded-IAM JWT (e.g. an API key, a superuser
|
|
// token, or simply missing), the middleware no-ops and lets the
|
|
// regular auth pipeline run.
|
|
func (p *plugin) embeddedIAMAuthMiddleware(e *core.RequestEvent) error {
|
|
if p.embeddedIAM == nil || e.Auth != nil {
|
|
return e.Next()
|
|
}
|
|
|
|
auth := e.Request.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|
return e.Next()
|
|
}
|
|
raw := strings.TrimPrefix(auth, "Bearer ")
|
|
if raw == "" {
|
|
return e.Next()
|
|
}
|
|
|
|
parsed, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
|
return nil, fmt.Errorf("unexpected alg %v", t.Header["alg"])
|
|
}
|
|
return &p.embeddedIAM.signer.PublicKey, nil
|
|
}, jwt.WithValidMethods([]string{"RS256"}))
|
|
if err != nil || !parsed.Valid {
|
|
// Not our token (e.g. an opaque API key). Let other middleware
|
|
// take a turn.
|
|
return e.Next()
|
|
}
|
|
|
|
claims, ok := parsed.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return e.Next()
|
|
}
|
|
sub, _ := claims["sub"].(string)
|
|
if sub == "" {
|
|
return e.Next()
|
|
}
|
|
rec, err := p.app.FindRecordById(collectionIAMUsers, sub)
|
|
if err != nil || rec == nil {
|
|
return e.Next()
|
|
}
|
|
|
|
email, _ := claims["email"].(string)
|
|
name, _ := claims["name"].(string)
|
|
|
|
// If a _superusers record exists for the same email, prefer it so
|
|
// admin operations (collections, settings, logs) authorize without
|
|
// a second login. Identity comes from IAM; admin privilege is the
|
|
// presence of a _superusers row keyed by email.
|
|
if email != "" {
|
|
if su, suErr := p.app.FindAuthRecordByEmail(core.CollectionNameSuperusers, email); suErr == nil && su != nil {
|
|
rec = su
|
|
}
|
|
}
|
|
|
|
e.Set("authSub", rec.Id)
|
|
e.Set("authEmail", email)
|
|
e.Set("authName", name)
|
|
e.Auth = rec
|
|
return e.Next()
|
|
}
|
|
|
|
// iamModeLabel returns a human-readable IAM mode for log lines.
|
|
func iamModeLabel(embedded bool) string {
|
|
if embedded {
|
|
return "embedded"
|
|
}
|
|
return "external"
|
|
}
|