mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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.
673 lines
22 KiB
Go
673 lines
22 KiB
Go
package platform
|
|
|
|
// Social-OAuth (Google, GitHub) and SIWE (Sign-In With Ethereum)
|
|
// providers for the embedded IAM. Each provider is opt-in via env so
|
|
// the embedded IAM ships zero-config by default and a deployment can
|
|
// turn on any subset without touching code:
|
|
//
|
|
// GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET → Continue with Google
|
|
// GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET → Continue with GitHub
|
|
// WALLET_LOGIN_ENABLED=true → Connect Wallet (SIWE)
|
|
//
|
|
// All three flows funnel into the existing `pendingCode` mechanism so
|
|
// the OIDC PKCE contract is identical to the email/password path: the
|
|
// caller's `redirect_uri` ends up with `?code=<our-opaque>&state=...`
|
|
// and the token endpoint trades it for our RS256 JWT.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/router"
|
|
luxcrypto "github.com/luxfi/crypto"
|
|
)
|
|
|
|
// socialProvider is the minimal OAuth2 + OIDC userinfo plumbing the
|
|
// embedded IAM needs to ferry a user from a 3rd-party login back into
|
|
// our `_iam_users` table.
|
|
type socialProvider struct {
|
|
Key string // url-safe slug used in /v1/iam/oauth/social/{key}
|
|
Name string // human label rendered on the button ("Google")
|
|
AuthURL string
|
|
TokenURL string
|
|
UserinfoURL string
|
|
Scopes string
|
|
ClientID string
|
|
ClientSecret string
|
|
}
|
|
|
|
// enabledSocialProviders returns the social providers configured via
|
|
// env at process start. Empty slice = no buttons rendered.
|
|
func enabledSocialProviders() []*socialProvider {
|
|
var out []*socialProvider
|
|
if id, secret := os.Getenv("GOOGLE_CLIENT_ID"), os.Getenv("GOOGLE_CLIENT_SECRET"); id != "" && secret != "" {
|
|
out = append(out, &socialProvider{
|
|
Key: "google",
|
|
Name: "Google",
|
|
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
TokenURL: "https://oauth2.googleapis.com/token",
|
|
UserinfoURL: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
Scopes: "openid email profile",
|
|
ClientID: id,
|
|
ClientSecret: secret,
|
|
})
|
|
}
|
|
if id, secret := os.Getenv("GITHUB_CLIENT_ID"), os.Getenv("GITHUB_CLIENT_SECRET"); id != "" && secret != "" {
|
|
out = append(out, &socialProvider{
|
|
Key: "github",
|
|
Name: "GitHub",
|
|
AuthURL: "https://github.com/login/oauth/authorize",
|
|
TokenURL: "https://github.com/login/oauth/access_token",
|
|
UserinfoURL: "https://api.github.com/user",
|
|
Scopes: "read:user user:email",
|
|
ClientID: id,
|
|
ClientSecret: secret,
|
|
})
|
|
}
|
|
if id, secret := os.Getenv("APPLE_CLIENT_ID"), os.Getenv("APPLE_CLIENT_SECRET"); id != "" && secret != "" {
|
|
out = append(out, &socialProvider{
|
|
Key: "apple",
|
|
Name: "Apple",
|
|
AuthURL: "https://appleid.apple.com/auth/authorize",
|
|
TokenURL: "https://appleid.apple.com/auth/token",
|
|
UserinfoURL: "", // Apple returns the id_token at /auth/token; no separate userinfo endpoint.
|
|
Scopes: "name email",
|
|
ClientID: id,
|
|
ClientSecret: secret, // Apple expects a signed JWT here; operator pre-mints it.
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func emailOTPEnabled() bool {
|
|
// Email-OTP is enabled when SMTP credentials are configured. We
|
|
// don't gate on a separate flag so the same env that turns on
|
|
// email notifications turns on passwordless email login.
|
|
return os.Getenv("SMTP_HOST") != "" || os.Getenv("EMAIL_OTP_ENABLED") == "true"
|
|
}
|
|
|
|
func smsOTPEnabled() bool {
|
|
// Twilio is the canonical SMS adapter. Custom adapters can set
|
|
// SMS_OTP_ENABLED=true + their own delivery wiring.
|
|
if os.Getenv("TWILIO_ACCOUNT_SID") != "" && os.Getenv("TWILIO_AUTH_TOKEN") != "" {
|
|
return true
|
|
}
|
|
return os.Getenv("SMS_OTP_ENABLED") == "true"
|
|
}
|
|
|
|
func walletLoginEnabled() bool {
|
|
return os.Getenv("WALLET_LOGIN_ENABLED") == "true"
|
|
}
|
|
|
|
// requireSecondFactor returns true when the operator wants every
|
|
// primary authentication followed by an OTP step. Per-user opt-in is
|
|
// the next iteration — for now this is a tenant-wide knob.
|
|
func requireSecondFactor() bool {
|
|
return os.Getenv("IAM_REQUIRE_2FA") == "true"
|
|
}
|
|
|
|
// AuthMethod is the contract the SPA reads from /v1/iam/auth/methods
|
|
// to render only the buttons that are actually enabled server-side.
|
|
// One source of truth: the server emits what's wired, the client
|
|
// renders exactly that. No client-side guessing, no env mismatch.
|
|
type AuthMethod struct {
|
|
Kind string `json:"kind"` // "password" | "social" | "wallet" | "email_otp" | "sms_otp"
|
|
Provider string `json:"provider,omitempty"` // "google" | "github" | "apple" (when kind=social)
|
|
Label string `json:"label"`
|
|
IsPrimary bool `json:"is_primary"` // true = can start a login; false = 2FA step only
|
|
IsSecondary bool `json:"is_secondary"`// true = available as 2FA step
|
|
}
|
|
|
|
// EnabledAuthMethods returns the full live list of enabled methods.
|
|
// Wallet first (per UX directive: most distinct method), then social,
|
|
// then email-OTP/SMS-OTP if configured, finally password (always on
|
|
// as the universal fallback).
|
|
func EnabledAuthMethods() []AuthMethod {
|
|
out := []AuthMethod{}
|
|
if walletLoginEnabled() {
|
|
out = append(out, AuthMethod{Kind: "wallet", Label: "Connect Wallet", IsPrimary: true})
|
|
}
|
|
for _, p := range enabledSocialProviders() {
|
|
out = append(out, AuthMethod{Kind: "social", Provider: p.Key, Label: "Continue with " + p.Name, IsPrimary: true})
|
|
}
|
|
if emailOTPEnabled() {
|
|
out = append(out, AuthMethod{Kind: "email_otp", Label: "Email code", IsPrimary: true, IsSecondary: true})
|
|
}
|
|
if smsOTPEnabled() {
|
|
out = append(out, AuthMethod{Kind: "sms_otp", Label: "SMS code", IsPrimary: false, IsSecondary: true})
|
|
}
|
|
out = append(out, AuthMethod{Kind: "password", Label: "Email + password", IsPrimary: true})
|
|
return out
|
|
}
|
|
|
|
// pendingFlow holds the OIDC PKCE params from the original /authorize
|
|
// request so the social-OAuth callback (or wallet verify) can resume
|
|
// the flow without server-side session cookies. Keyed by a random
|
|
// state string used as the OAuth `state` param.
|
|
type pendingFlow struct {
|
|
clientID string
|
|
redirectURI string
|
|
state string // the original state from the caller
|
|
expires time.Time
|
|
}
|
|
|
|
var pendingFlows = struct {
|
|
sync.Mutex
|
|
m map[string]*pendingFlow
|
|
}{m: map[string]*pendingFlow{}}
|
|
|
|
const pendingFlowTTL = 10 * time.Minute
|
|
|
|
func pendingFlowsPut(key string, f *pendingFlow) {
|
|
pendingFlows.Lock()
|
|
defer pendingFlows.Unlock()
|
|
// Opportunistic GC.
|
|
now := time.Now()
|
|
for k, v := range pendingFlows.m {
|
|
if now.After(v.expires) {
|
|
delete(pendingFlows.m, k)
|
|
}
|
|
}
|
|
pendingFlows.m[key] = f
|
|
}
|
|
|
|
func pendingFlowsTake(key string) (*pendingFlow, bool) {
|
|
pendingFlows.Lock()
|
|
defer pendingFlows.Unlock()
|
|
f, ok := pendingFlows.m[key]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
delete(pendingFlows.m, key)
|
|
if time.Now().After(f.expires) {
|
|
return nil, false
|
|
}
|
|
return f, true
|
|
}
|
|
|
|
func randomURLSafe(n int) (string, error) {
|
|
buf := make([]byte, n)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
// findProvider returns the configured provider matching key, or nil.
|
|
func findProvider(key string) *socialProvider {
|
|
for _, p := range enabledSocialProviders() {
|
|
if p.Key == key {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Routes
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (p *plugin) registerEmbeddedSocialRoutes(r *router.Router[*core.RequestEvent]) {
|
|
if p.embeddedIAM == nil {
|
|
return
|
|
}
|
|
r.GET(embeddedIAMMount+"/oauth/social/{provider}", p.handleSocialStart)
|
|
r.GET(embeddedIAMMount+"/oauth/social/{provider}/callback", p.handleSocialCallback)
|
|
if walletLoginEnabled() {
|
|
r.POST(embeddedIAMMount+"/oauth/wallet/challenge", p.handleWalletChallenge)
|
|
r.POST(embeddedIAMMount+"/oauth/wallet/verify", p.handleWalletVerify)
|
|
}
|
|
}
|
|
|
|
// handleSocialStart redirects the user to the upstream provider's
|
|
// authorize endpoint with our callback URL and a random state. The
|
|
// state also keys a `pendingFlow` so the callback can resume the
|
|
// original OIDC PKCE flow.
|
|
func (p *plugin) handleSocialStart(e *core.RequestEvent) error {
|
|
key := e.Request.PathValue("provider")
|
|
prov := findProvider(key)
|
|
if prov == nil {
|
|
return e.NotFoundError("unknown provider", nil)
|
|
}
|
|
|
|
q := e.Request.URL.Query()
|
|
clientID := q.Get("client_id")
|
|
redirectURI := q.Get("redirect_uri")
|
|
origState := q.Get("state")
|
|
if clientID == "" || redirectURI == "" {
|
|
return e.BadRequestError("client_id and redirect_uri are required", nil)
|
|
}
|
|
|
|
state, err := randomURLSafe(32)
|
|
if err != nil {
|
|
return e.InternalServerError("state", err)
|
|
}
|
|
pendingFlowsPut(state, &pendingFlow{
|
|
clientID: clientID,
|
|
redirectURI: redirectURI,
|
|
state: origState,
|
|
expires: time.Now().Add(pendingFlowTTL),
|
|
})
|
|
|
|
cb := requestOrigin(e.Request) + embeddedIAMMount + "/oauth/social/" + prov.Key + "/callback"
|
|
authURL := prov.AuthURL + "?" + url.Values{
|
|
"client_id": {prov.ClientID},
|
|
"redirect_uri": {cb},
|
|
"response_type": {"code"},
|
|
"scope": {prov.Scopes},
|
|
"state": {state},
|
|
}.Encode()
|
|
|
|
e.Response.Header().Set("Location", authURL)
|
|
e.Response.WriteHeader(http.StatusFound)
|
|
return nil
|
|
}
|
|
|
|
// handleSocialCallback runs after the provider redirects back. It
|
|
// exchanges the code for an access token, fetches user info, finds or
|
|
// creates a `_iam_users` record by email, then continues the original
|
|
// OIDC PKCE flow by redirecting to the caller's redirect_uri with our
|
|
// own opaque code.
|
|
func (p *plugin) handleSocialCallback(e *core.RequestEvent) error {
|
|
key := e.Request.PathValue("provider")
|
|
prov := findProvider(key)
|
|
if prov == nil {
|
|
return e.NotFoundError("unknown provider", nil)
|
|
}
|
|
|
|
q := e.Request.URL.Query()
|
|
code := q.Get("code")
|
|
state := q.Get("state")
|
|
if code == "" || state == "" {
|
|
return e.BadRequestError("missing code or state", nil)
|
|
}
|
|
|
|
flow, ok := pendingFlowsTake(state)
|
|
if !ok {
|
|
return e.BadRequestError("expired or unknown state", nil)
|
|
}
|
|
|
|
email, name, err := exchangeAndFetch(e.Request.Context(), prov, code, requestOrigin(e.Request))
|
|
if err != nil {
|
|
return e.Error(http.StatusBadGateway, "provider exchange failed: "+err.Error(), nil)
|
|
}
|
|
if email == "" {
|
|
return e.Error(http.StatusBadGateway, "provider returned no email", nil)
|
|
}
|
|
|
|
user, err := p.findOrCreateUserByEmail(email, name)
|
|
if err != nil {
|
|
return e.InternalServerError("user upsert", err)
|
|
}
|
|
|
|
return p.issueCodeAndRedirect(e, user, flow)
|
|
}
|
|
|
|
// exchangeAndFetch posts the auth code to the provider's token
|
|
// endpoint, then GETs the userinfo URL with the resulting access
|
|
// token. Returns email + name.
|
|
func exchangeAndFetch(ctx interface{ Done() <-chan struct{} }, prov *socialProvider, code, origin string) (email, name string, err error) {
|
|
cb := origin + embeddedIAMMount + "/oauth/social/" + prov.Key + "/callback"
|
|
form := url.Values{
|
|
"code": {code},
|
|
"client_id": {prov.ClientID},
|
|
"client_secret": {prov.ClientSecret},
|
|
"redirect_uri": {cb},
|
|
"grant_type": {"authorization_code"},
|
|
}
|
|
req, _ := http.NewRequest("POST", prov.TokenURL, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return "", "", fmt.Errorf("token endpoint %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
var tokResp struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
if err := json.Unmarshal(body, &tokResp); err != nil || tokResp.AccessToken == "" {
|
|
// GitHub sometimes returns form-encoded — fall back to that.
|
|
vals, perr := url.ParseQuery(string(body))
|
|
if perr != nil || vals.Get("access_token") == "" {
|
|
return "", "", fmt.Errorf("invalid token response: %s", string(body))
|
|
}
|
|
tokResp.AccessToken = vals.Get("access_token")
|
|
}
|
|
|
|
uReq, _ := http.NewRequest("GET", prov.UserinfoURL, nil)
|
|
uReq.Header.Set("Authorization", "Bearer "+tokResp.AccessToken)
|
|
uReq.Header.Set("Accept", "application/json")
|
|
uResp, err := http.DefaultClient.Do(uReq)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer uResp.Body.Close()
|
|
uBody, _ := io.ReadAll(uResp.Body)
|
|
if uResp.StatusCode >= 400 {
|
|
return "", "", fmt.Errorf("userinfo %d: %s", uResp.StatusCode, string(uBody))
|
|
}
|
|
var info map[string]any
|
|
if err := json.Unmarshal(uBody, &info); err != nil {
|
|
return "", "", err
|
|
}
|
|
// OIDC / Google: "email", "name". GitHub: "email" can be null
|
|
// (use the additional /user/emails endpoint), "login" as fallback name.
|
|
email, _ = info["email"].(string)
|
|
name, _ = info["name"].(string)
|
|
if name == "" {
|
|
if v, ok := info["login"].(string); ok {
|
|
name = v
|
|
}
|
|
}
|
|
if email == "" && prov.Key == "github" {
|
|
// GitHub: when the user's primary email is private, the /user
|
|
// endpoint returns email=null. Fetch /user/emails and pick
|
|
// the verified primary.
|
|
if e, err := githubPrimaryEmail(tokResp.AccessToken); err == nil {
|
|
email = e
|
|
}
|
|
}
|
|
return email, name, nil
|
|
}
|
|
|
|
func githubPrimaryEmail(accessToken string) (string, error) {
|
|
req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return "", fmt.Errorf("/user/emails %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
var emails []struct {
|
|
Email string `json:"email"`
|
|
Primary bool `json:"primary"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
if err := json.Unmarshal(body, &emails); err != nil {
|
|
return "", err
|
|
}
|
|
for _, e := range emails {
|
|
if e.Primary && e.Verified {
|
|
return e.Email, nil
|
|
}
|
|
}
|
|
return "", errors.New("no verified primary email")
|
|
}
|
|
|
|
// findOrCreateUserByEmail is the shared upsert for social + wallet
|
|
// login. Password is empty for these users — they can only sign in
|
|
// via their original provider.
|
|
func (p *plugin) findOrCreateUserByEmail(email, name string) (*core.Record, error) {
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
if email == "" {
|
|
return nil, errors.New("email required")
|
|
}
|
|
rec, err := p.app.FindFirstRecordByData(collectionIAMUsers, "email", email)
|
|
if err == nil && rec != nil {
|
|
return rec, nil
|
|
}
|
|
col, err := p.app.FindCollectionByNameOrId(collectionIAMUsers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rec = core.NewRecord(col)
|
|
rec.Set("email", email)
|
|
rec.Set("name", name)
|
|
// Empty password — social/wallet users can't sign in with a password.
|
|
// The bcrypt validator in AuthenticateEmbeddedIAMUser will reject it,
|
|
// which is the correct behavior.
|
|
rec.Set("password", "")
|
|
if err := p.app.Save(rec); err != nil {
|
|
return nil, err
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// issueCodeAndRedirect mirrors handleEmbeddedLogin's tail: mint an
|
|
// opaque code keyed to the user + original flow, then redirect the
|
|
// browser back to the original redirect_uri so the OIDC PKCE
|
|
// caller continues unchanged.
|
|
func (p *plugin) issueCodeAndRedirect(e *core.RequestEvent, user *core.Record, flow *pendingFlow) error {
|
|
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: flow.clientID,
|
|
redirectURI: flow.redirectURI,
|
|
expires: time.Now().Add(embeddedIAMCodeTTL),
|
|
}
|
|
p.embeddedIAM.evictExpiredCodesLocked()
|
|
p.embeddedIAM.mu.Unlock()
|
|
|
|
u, err := url.Parse(flow.redirectURI)
|
|
if err != nil {
|
|
return e.BadRequestError("invalid redirect_uri", err)
|
|
}
|
|
q := u.Query()
|
|
q.Set("code", code)
|
|
if flow.state != "" {
|
|
q.Set("state", flow.state)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
e.Response.Header().Set("Location", u.String())
|
|
e.Response.WriteHeader(http.StatusFound)
|
|
return nil
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// SIWE (wallet) login — EIP-4361
|
|
// --------------------------------------------------------------------------
|
|
|
|
var walletAddressRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`)
|
|
|
|
// walletNonces tracks the nonce → pendingFlow pairing so /verify can
|
|
// finish the OIDC flow after the wallet signs the SIWE message.
|
|
var walletNonces = struct {
|
|
sync.Mutex
|
|
m map[string]*pendingFlow
|
|
}{m: map[string]*pendingFlow{}}
|
|
|
|
type walletChallengeReq struct {
|
|
Address string `json:"address"`
|
|
ClientID string `json:"client_id"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
type walletChallengeResp struct {
|
|
Nonce string `json:"nonce"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (p *plugin) handleWalletChallenge(e *core.RequestEvent) error {
|
|
var req walletChallengeReq
|
|
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
|
return e.BadRequestError("invalid body", err)
|
|
}
|
|
if !walletAddressRe.MatchString(req.Address) {
|
|
return e.BadRequestError("invalid address", nil)
|
|
}
|
|
if req.ClientID == "" || req.RedirectURI == "" {
|
|
return e.BadRequestError("client_id and redirect_uri are required", nil)
|
|
}
|
|
|
|
nonce, err := randomURLSafe(16)
|
|
if err != nil {
|
|
return e.InternalServerError("nonce", err)
|
|
}
|
|
|
|
walletNonces.Lock()
|
|
// GC stale.
|
|
now := time.Now()
|
|
for k, v := range walletNonces.m {
|
|
if now.After(v.expires) {
|
|
delete(walletNonces.m, k)
|
|
}
|
|
}
|
|
walletNonces.m[nonce] = &pendingFlow{
|
|
clientID: req.ClientID,
|
|
redirectURI: req.RedirectURI,
|
|
state: req.State,
|
|
expires: now.Add(5 * time.Minute),
|
|
}
|
|
walletNonces.Unlock()
|
|
|
|
domain := e.Request.Host
|
|
uri := requestOrigin(e.Request)
|
|
issuedAt := time.Now().UTC().Format(time.RFC3339)
|
|
msg := fmt.Sprintf(
|
|
"%s wants you to sign in with your Ethereum account:\n%s\n\nSign in to %s\n\nURI: %s\nVersion: 1\nChain ID: 1\nNonce: %s\nIssued At: %s",
|
|
domain, req.Address, domain, uri, nonce, issuedAt,
|
|
)
|
|
|
|
return e.JSON(http.StatusOK, walletChallengeResp{
|
|
Nonce: nonce,
|
|
Message: msg,
|
|
})
|
|
}
|
|
|
|
type walletVerifyReq struct {
|
|
Address string `json:"address"`
|
|
Message string `json:"message"`
|
|
Signature string `json:"signature"`
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
|
|
func (p *plugin) handleWalletVerify(e *core.RequestEvent) error {
|
|
var req walletVerifyReq
|
|
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
|
return e.BadRequestError("invalid body", err)
|
|
}
|
|
if !walletAddressRe.MatchString(req.Address) {
|
|
return e.BadRequestError("invalid address", nil)
|
|
}
|
|
|
|
walletNonces.Lock()
|
|
flow, ok := walletNonces.m[req.Nonce]
|
|
if ok {
|
|
delete(walletNonces.m, req.Nonce)
|
|
}
|
|
walletNonces.Unlock()
|
|
if !ok || time.Now().After(flow.expires) {
|
|
return e.BadRequestError("expired or unknown nonce", nil)
|
|
}
|
|
if !strings.Contains(req.Message, "Nonce: "+req.Nonce) {
|
|
return e.BadRequestError("nonce missing from message", nil)
|
|
}
|
|
if !strings.Contains(strings.ToLower(req.Message), strings.ToLower(req.Address)) {
|
|
return e.BadRequestError("address missing from message", nil)
|
|
}
|
|
|
|
recovered, err := siweRecoverAddress(req.Message, req.Signature)
|
|
if err != nil {
|
|
return e.BadRequestError("invalid signature: "+err.Error(), nil)
|
|
}
|
|
if !strings.EqualFold(recovered, req.Address) {
|
|
return e.BadRequestError("signature does not match address", nil)
|
|
}
|
|
|
|
// Synthesize an email keyed on the wallet so the find-or-create
|
|
// path stays uniform. White-label deployments can rewrite this
|
|
// suffix later without a schema change.
|
|
email := strings.ToLower(req.Address) + "@wallet.local"
|
|
user, err := p.findOrCreateUserByEmail(email, "Wallet "+req.Address[:6]+"…"+req.Address[len(req.Address)-4:])
|
|
if err != nil {
|
|
return e.InternalServerError("user upsert", err)
|
|
}
|
|
|
|
// Build the redirect target the same way the social callback does.
|
|
// The browser POSTed here via fetch, so return the URL as JSON;
|
|
// the client-side script does window.location = url.
|
|
u, err := url.Parse(flow.redirectURI)
|
|
if err != nil {
|
|
return e.BadRequestError("invalid redirect_uri", 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: flow.clientID,
|
|
redirectURI: flow.redirectURI,
|
|
expires: time.Now().Add(embeddedIAMCodeTTL),
|
|
}
|
|
p.embeddedIAM.evictExpiredCodesLocked()
|
|
p.embeddedIAM.mu.Unlock()
|
|
|
|
q := u.Query()
|
|
q.Set("code", code)
|
|
if flow.state != "" {
|
|
q.Set("state", flow.state)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
return e.JSON(http.StatusOK, map[string]string{"redirect": u.String()})
|
|
}
|
|
|
|
// siweRecoverAddress runs eth_personalSign verification: prefix the
|
|
// message with the Ethereum signed-data preamble, keccak256, then
|
|
// ecrecover the signer's public key, and derive the 0x address.
|
|
func siweRecoverAddress(message, sigHex string) (string, error) {
|
|
sig, err := hex.DecodeString(strings.TrimPrefix(sigHex, "0x"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode signature: %w", err)
|
|
}
|
|
if len(sig) != 65 {
|
|
return "", fmt.Errorf("signature length %d != 65", len(sig))
|
|
}
|
|
// Ethereum encodes the recovery id at the end (v); luxcrypto.Ecrecover
|
|
// expects v in {0,1}.
|
|
if sig[64] >= 27 {
|
|
sig[64] -= 27
|
|
}
|
|
|
|
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))
|
|
hash := luxcrypto.Keccak256([]byte(prefix), []byte(message))
|
|
|
|
pub, err := luxcrypto.Ecrecover(hash, sig)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ecrecover: %w", err)
|
|
}
|
|
if len(pub) < 65 || pub[0] != 0x04 {
|
|
return "", fmt.Errorf("unexpected pubkey format")
|
|
}
|
|
// Address = last 20 bytes of keccak256(pub[1:]).
|
|
addrHash := luxcrypto.Keccak256(pub[1:])
|
|
addr := addrHash[len(addrHash)-20:]
|
|
return "0x" + hex.EncodeToString(addr), nil
|
|
}
|