network/pkg/gateway/auth_handlers.go
anonpenguin 076edf4208 Fix code style and indentation
Here's the commit message:

``` Fix code style and indentation

Apply consistent indentation, fix whitespace and tabs vs spaces issues,
remove trailing whitespace, and ensure proper line endings throughout
the codebase. Also add comments and improve code organization. ```

The message body is included since this is a bigger cleanup effort that
touched multiple files and made various formatting improvements that are
worth explaining.
2025-08-20 11:27:08 +03:00

1014 lines
33 KiB
Go

package gateway
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.debros.io/DeBros/network/pkg/storage"
ethcrypto "github.com/ethereum/go-ethereum/crypto"
)
func (g *Gateway) whoamiHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Determine namespace (may be overridden by auth layer)
ns := g.cfg.ClientNamespace
if v := ctx.Value(storage.CtxKeyNamespaceOverride); v != nil {
if s, ok := v.(string); ok && s != "" {
ns = s
}
}
// Prefer JWT if present
if v := ctx.Value(ctxKeyJWT); v != nil {
if claims, ok := v.(*jwtClaims); ok && claims != nil {
writeJSON(w, http.StatusOK, map[string]any{
"authenticated": true,
"method": "jwt",
"subject": claims.Sub,
"issuer": claims.Iss,
"audience": claims.Aud,
"issued_at": claims.Iat,
"not_before": claims.Nbf,
"expires_at": claims.Exp,
"namespace": ns,
})
return
}
}
// Fallback: API key identity
var key string
if v := ctx.Value(ctxKeyAPIKey); v != nil {
if s, ok := v.(string); ok {
key = s
}
}
writeJSON(w, http.StatusOK, map[string]any{
"authenticated": key != "",
"method": "api_key",
"api_key": key,
"namespace": ns,
})
}
func (g *Gateway) challengeHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Wallet string `json:"wallet"`
Purpose string `json:"purpose"`
Namespace string `json:"namespace"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if strings.TrimSpace(req.Wallet) == "" {
writeError(w, http.StatusBadRequest, "wallet is required")
return
}
ns := strings.TrimSpace(req.Namespace)
if ns == "" {
ns = strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" {
ns = "default"
}
}
// Generate a URL-safe random nonce (32 bytes)
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate nonce")
return
}
nonce := base64.RawURLEncoding.EncodeToString(buf)
// Insert namespace if missing, fetch id
ctx := r.Context()
db := g.client.Database()
if _, err := db.Query(ctx, "INSERT OR IGNORE INTO namespaces(name) VALUES (?)", ns); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
nres, err := db.Query(ctx, "SELECT id FROM namespaces WHERE name = ? LIMIT 1", ns)
if err != nil || nres == nil || nres.Count == 0 || len(nres.Rows) == 0 || len(nres.Rows[0]) == 0 {
writeError(w, http.StatusInternalServerError, "failed to resolve namespace")
return
}
nsID := nres.Rows[0][0]
// Store nonce with 5 minute expiry
if _, err := db.Query(ctx,
"INSERT INTO nonces(namespace_id, wallet, nonce, purpose, expires_at) VALUES (?, ?, ?, ?, datetime('now', '+5 minutes'))",
nsID, req.Wallet, nonce, req.Purpose,
); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"wallet": req.Wallet,
"namespace": ns,
"nonce": nonce,
"purpose": req.Purpose,
"expires_at": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339Nano),
})
}
func (g *Gateway) verifyHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Wallet string `json:"wallet"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
Namespace string `json:"namespace"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if strings.TrimSpace(req.Wallet) == "" || strings.TrimSpace(req.Nonce) == "" || strings.TrimSpace(req.Signature) == "" {
writeError(w, http.StatusBadRequest, "wallet, nonce and signature are required")
return
}
ns := strings.TrimSpace(req.Namespace)
if ns == "" {
ns = strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" {
ns = "default"
}
}
ctx := r.Context()
db := g.client.Database()
nsID, err := g.resolveNamespaceID(ctx, ns)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
q := "SELECT id FROM nonces WHERE namespace_id = ? AND wallet = ? AND nonce = ? AND used_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
nres, err := db.Query(ctx, q, nsID, req.Wallet, req.Nonce)
if err != nil || nres == nil || nres.Count == 0 {
writeError(w, http.StatusBadRequest, "invalid or expired nonce")
return
}
nonceID := nres.Rows[0][0]
// EVM personal_sign verification of the nonce
// Hash: keccak256("\x19Ethereum Signed Message:\n" + len(nonce) + nonce)
msg := []byte(req.Nonce)
prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg)))
hash := ethcrypto.Keccak256(prefix, msg)
// Decode signature (expects 65-byte r||s||v, hex with optional 0x)
sigHex := strings.TrimSpace(req.Signature)
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") {
sigHex = sigHex[2:]
}
sig, err := hex.DecodeString(sigHex)
if err != nil || len(sig) != 65 {
writeError(w, http.StatusBadRequest, "invalid signature format")
return
}
// Normalize V to 0/1 as expected by geth
if sig[64] >= 27 {
sig[64] -= 27
}
pub, err := ethcrypto.SigToPub(hash, sig)
if err != nil {
writeError(w, http.StatusUnauthorized, "signature recovery failed")
return
}
addr := ethcrypto.PubkeyToAddress(*pub).Hex()
want := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X"))
got := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X"))
if got != want {
writeError(w, http.StatusUnauthorized, "signature does not match wallet")
return
}
// Mark nonce used now (after successful verification)
if _, err := db.Query(ctx, "UPDATE nonces SET used_at = datetime('now') WHERE id = ?", nonceID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if g.signingKey == nil {
writeError(w, http.StatusServiceUnavailable, "signing key unavailable")
return
}
// Issue access token (15m) and a refresh token (30d)
token, expUnix, err := g.generateJWT(ns, req.Wallet, 15*time.Minute)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// create refresh token
rbuf := make([]byte, 32)
if _, err := rand.Read(rbuf); err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate refresh token")
return
}
refresh := base64.RawURLEncoding.EncodeToString(rbuf)
if _, err := db.Query(ctx, "INSERT INTO refresh_tokens(namespace_id, subject, token, audience, expires_at) VALUES (?, ?, ?, ?, datetime('now', '+30 days'))", nsID, req.Wallet, refresh, "gateway"); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token,
"token_type": "Bearer",
"expires_in": int(expUnix - time.Now().Unix()),
"refresh_token": refresh,
"subject": req.Wallet,
"namespace": ns,
"nonce": req.Nonce,
"signature_verified": true,
})
}
// issueAPIKeyHandler creates or returns an API key for a verified wallet in a namespace.
// Requires: POST { wallet, nonce, signature, namespace }
// Behavior:
// - Validates nonce and signature like verifyHandler
// - Ensures namespace exists
// - If an API key already exists for (namespace, wallet), returns it; else creates one
// - Records namespace ownership mapping for the wallet and api_key
func (g *Gateway) issueAPIKeyHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Wallet string `json:"wallet"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
Namespace string `json:"namespace"`
Plan string `json:"plan"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if strings.TrimSpace(req.Wallet) == "" || strings.TrimSpace(req.Nonce) == "" || strings.TrimSpace(req.Signature) == "" {
writeError(w, http.StatusBadRequest, "wallet, nonce and signature are required")
return
}
ns := strings.TrimSpace(req.Namespace)
if ns == "" {
ns = strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" {
ns = "default"
}
}
ctx := r.Context()
db := g.client.Database()
// Resolve namespace id
nsID, err := g.resolveNamespaceID(ctx, ns)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Validate nonce exists and not used/expired
q := "SELECT id FROM nonces WHERE namespace_id = ? AND wallet = ? AND nonce = ? AND used_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
nres, err := db.Query(ctx, q, nsID, req.Wallet, req.Nonce)
if err != nil || nres == nil || nres.Count == 0 {
writeError(w, http.StatusBadRequest, "invalid or expired nonce")
return
}
nonceID := nres.Rows[0][0]
// Verify signature like verifyHandler
msg := []byte(req.Nonce)
prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg)))
hash := ethcrypto.Keccak256(prefix, msg)
sigHex := strings.TrimSpace(req.Signature)
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") {
sigHex = sigHex[2:]
}
sig, err := hex.DecodeString(sigHex)
if err != nil || len(sig) != 65 {
writeError(w, http.StatusBadRequest, "invalid signature format")
return
}
if sig[64] >= 27 {
sig[64] -= 27
}
pub, err := ethcrypto.SigToPub(hash, sig)
if err != nil {
writeError(w, http.StatusUnauthorized, "signature recovery failed")
return
}
addr := ethcrypto.PubkeyToAddress(*pub).Hex()
want := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X"))
got := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X"))
if got != want {
writeError(w, http.StatusUnauthorized, "signature does not match wallet")
return
}
// Mark nonce used
if _, err := db.Query(ctx, "UPDATE nonces SET used_at = datetime('now') WHERE id = ?", nonceID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Check if api key exists for (namespace, wallet) via linkage table
var apiKey string
r1, err := db.Query(ctx, "SELECT api_keys.key FROM wallet_api_keys JOIN api_keys ON wallet_api_keys.api_key_id = api_keys.id WHERE wallet_api_keys.namespace_id = ? AND LOWER(wallet_api_keys.wallet) = LOWER(?) LIMIT 1", nsID, req.Wallet)
if err == nil && r1 != nil && r1.Count > 0 && len(r1.Rows) > 0 && len(r1.Rows[0]) > 0 {
if s, ok := r1.Rows[0][0].(string); ok {
apiKey = s
} else {
b, _ := json.Marshal(r1.Rows[0][0])
_ = json.Unmarshal(b, &apiKey)
}
}
if strings.TrimSpace(apiKey) == "" {
// Create new API key with format ak_<random>:<namespace>
buf := make([]byte, 18)
if _, err := rand.Read(buf); err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate api key")
return
}
apiKey = "ak_" + base64.RawURLEncoding.EncodeToString(buf) + ":" + ns
if _, err := db.Query(ctx, "INSERT INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", apiKey, "", nsID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Create linkage
// Find api_key id
rid, err := db.Query(ctx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", apiKey)
if err == nil && rid != nil && rid.Count > 0 && len(rid.Rows) > 0 && len(rid.Rows[0]) > 0 {
apiKeyID := rid.Rows[0][0]
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO wallet_api_keys(namespace_id, wallet, api_key_id) VALUES (?, ?, ?)", nsID, strings.ToLower(req.Wallet), apiKeyID)
}
}
// Record ownerships (best-effort)
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, apiKey)
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'wallet', ?)", nsID, req.Wallet)
writeJSON(w, http.StatusOK, map[string]any{
"api_key": apiKey,
"namespace": ns,
"plan": func() string {
if strings.TrimSpace(req.Plan) == "" {
return "free"
} else {
return req.Plan
}
}(),
"wallet": strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")),
})
}
// apiKeyToJWTHandler issues a short-lived JWT for use with the gateway from a valid API key.
// Requires Authorization header with API key (Bearer or ApiKey or X-API-Key header).
// Returns a JWT bound to the namespace derived from the API key record.
func (g *Gateway) apiKeyToJWTHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
key := extractAPIKey(r)
if strings.TrimSpace(key) == "" {
writeError(w, http.StatusUnauthorized, "missing API key")
return
}
// Validate and get namespace
db := g.client.Database()
ctx := r.Context()
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
res, err := db.Query(ctx, q, key)
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
writeError(w, http.StatusUnauthorized, "invalid API key")
return
}
var ns string
if s, ok := res.Rows[0][0].(string); ok {
ns = s
} else {
b, _ := json.Marshal(res.Rows[0][0])
_ = json.Unmarshal(b, &ns)
}
ns = strings.TrimSpace(ns)
if ns == "" {
writeError(w, http.StatusUnauthorized, "invalid API key")
return
}
if g.signingKey == nil {
writeError(w, http.StatusServiceUnavailable, "signing key unavailable")
return
}
// Subject is the API key string for now
token, expUnix, err := g.generateJWT(ns, key, 15*time.Minute)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token,
"token_type": "Bearer",
"expires_in": int(expUnix - time.Now().Unix()),
"namespace": ns,
})
}
func (g *Gateway) registerHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
Wallet string `json:"wallet"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
Namespace string `json:"namespace"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if strings.TrimSpace(req.Wallet) == "" || strings.TrimSpace(req.Nonce) == "" || strings.TrimSpace(req.Signature) == "" {
writeError(w, http.StatusBadRequest, "wallet, nonce and signature are required")
return
}
ns := strings.TrimSpace(req.Namespace)
if ns == "" {
ns = strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" {
ns = "default"
}
}
ctx := r.Context()
db := g.client.Database()
nsID, err := g.resolveNamespaceID(ctx, ns)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Validate nonce
q := "SELECT id FROM nonces WHERE namespace_id = ? AND wallet = ? AND nonce = ? AND used_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
nres, err := db.Query(ctx, q, nsID, req.Wallet, req.Nonce)
if err != nil || nres == nil || nres.Count == 0 || len(nres.Rows) == 0 || len(nres.Rows[0]) == 0 {
writeError(w, http.StatusBadRequest, "invalid or expired nonce")
return
}
nonceID := nres.Rows[0][0]
// EVM personal_sign verification of the nonce
msg := []byte(req.Nonce)
prefix := []byte("\x19Ethereum Signed Message:\n" + strconv.Itoa(len(msg)))
hash := ethcrypto.Keccak256(prefix, msg)
// Decode signature (expects 65-byte r||s||v, hex with optional 0x)
sigHex := strings.TrimSpace(req.Signature)
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") {
sigHex = sigHex[2:]
}
sig, err := hex.DecodeString(sigHex)
if err != nil || len(sig) != 65 {
writeError(w, http.StatusBadRequest, "invalid signature format")
return
}
// Normalize V to 0/1 as expected by geth
if sig[64] >= 27 {
sig[64] -= 27
}
pub, err := ethcrypto.SigToPub(hash, sig)
if err != nil {
writeError(w, http.StatusUnauthorized, "signature recovery failed")
return
}
addr := ethcrypto.PubkeyToAddress(*pub).Hex()
want := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X"))
got := strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X"))
if got != want {
writeError(w, http.StatusUnauthorized, "signature does not match wallet")
return
}
// Mark nonce used now (after successful verification)
if _, err := db.Query(ctx, "UPDATE nonces SET used_at = datetime('now') WHERE id = ?", nonceID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Derive public key (uncompressed) hex
pubBytes := ethcrypto.FromECDSAPub(pub)
pubHex := "0x" + hex.EncodeToString(pubBytes)
// Generate client app_id
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate app id")
return
}
appID := "app_" + base64.RawURLEncoding.EncodeToString(buf)
// Persist app
if _, err := db.Query(ctx, "INSERT INTO apps(namespace_id, app_id, name, public_key) VALUES (?, ?, ?, ?)", nsID, appID, req.Name, pubHex); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Record namespace ownership by wallet (best-effort)
_, _ = db.Query(ctx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, ?, ?)", nsID, "wallet", req.Wallet)
writeJSON(w, http.StatusCreated, map[string]any{
"client_id": appID,
"app": map[string]any{
"app_id": appID,
"name": req.Name,
"public_key": pubHex,
"namespace": ns,
"wallet": strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(req.Wallet, "0x"), "0X")),
},
"signature_verified": true,
})
}
func (g *Gateway) refreshHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
RefreshToken string `json:"refresh_token"`
Namespace string `json:"namespace"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if strings.TrimSpace(req.RefreshToken) == "" {
writeError(w, http.StatusBadRequest, "refresh_token is required")
return
}
ns := strings.TrimSpace(req.Namespace)
if ns == "" {
ns = strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" {
ns = "default"
}
}
ctx := r.Context()
db := g.client.Database()
nsID, err := g.resolveNamespaceID(ctx, ns)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
q := "SELECT subject FROM refresh_tokens WHERE namespace_id = ? AND token = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
rres, err := db.Query(ctx, q, nsID, req.RefreshToken)
if err != nil || rres == nil || rres.Count == 0 {
writeError(w, http.StatusUnauthorized, "invalid or expired refresh token")
return
}
subject := ""
if len(rres.Rows) > 0 && len(rres.Rows[0]) > 0 {
if s, ok := rres.Rows[0][0].(string); ok {
subject = s
} else {
// fallback: format via json
b, _ := json.Marshal(rres.Rows[0][0])
_ = json.Unmarshal(b, &subject)
}
}
if g.signingKey == nil {
writeError(w, http.StatusServiceUnavailable, "signing key unavailable")
return
}
token, expUnix, err := g.generateJWT(ns, subject, 15*time.Minute)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token,
"token_type": "Bearer",
"expires_in": int(expUnix - time.Now().Unix()),
"refresh_token": req.RefreshToken,
"subject": subject,
"namespace": ns,
})
}
// loginPageHandler serves the wallet authentication login page
func (g *Gateway) loginPageHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
callbackURL := r.URL.Query().Get("callback")
if callbackURL == "" {
writeError(w, http.StatusBadRequest, "callback parameter is required")
return
}
// Get default namespace
ns := strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" {
ns = "default"
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DeBros Network - Wallet Authentication</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
margin: 0;
padding: 20px;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
padding: 40px;
max-width: 500px;
width: 100%%;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: bold;
color: #667eea;
margin-bottom: 10px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
.step {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: left;
}
.step-number {
background: #667eea;
color: white;
border-radius: 50%%;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 10px;
}
button {
background: #667eea;
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin: 10px;
}
button:hover {
background: #5a67d8;
transform: translateY(-1px);
}
button:disabled {
background: #cbd5e0;
cursor: not-allowed;
transform: none;
}
.error {
background: #fed7d7;
color: #e53e3e;
padding: 12px;
border-radius: 8px;
margin: 20px 0;
display: none;
}
.success {
background: #c6f6d5;
color: #2f855a;
padding: 12px;
border-radius: 8px;
margin: 20px 0;
display: none;
}
.loading {
display: none;
margin: 20px 0;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0%% { transform: rotate(0deg); }
100%% { transform: rotate(360deg); }
}
.namespace-info {
background: #e6fffa;
border: 1px solid #81e6d9;
border-radius: 8px;
padding: 15px;
margin: 20px 0;
}
.code {
font-family: 'Monaco', 'Menlo', monospace;
background: #f7fafc;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🌐 DeBros Network</div>
<p class="subtitle">Secure Wallet Authentication</p>
<div class="namespace-info">
<strong>📁 Namespace:</strong> <span class="code">%s</span>
</div>
<div class="step">
<div><span class="step-number">1</span><strong>Connect Your Wallet</strong></div>
<p>Click the button below to connect your Ethereum wallet (MetaMask, WalletConnect, etc.)</p>
</div>
<div class="step">
<div><span class="step-number">2</span><strong>Sign Authentication Message</strong></div>
<p>Your wallet will prompt you to sign a message to prove your identity. This is free and secure.</p>
</div>
<div class="step">
<div><span class="step-number">3</span><strong>Get Your API Key</strong></div>
<p>After signing, you'll receive an API key to access the DeBros Network.</p>
</div>
<div class="error" id="error"></div>
<div class="success" id="success"></div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Processing authentication...</p>
</div>
<button onclick="connectWallet()" id="connectBtn">🔗 Connect Wallet</button>
<button onclick="window.close()" style="background: #718096;">❌ Cancel</button>
</div>
<script>
const callbackURL = '%s';
const namespace = '%s';
let walletAddress = null;
async function connectWallet() {
const btn = document.getElementById('connectBtn');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const success = document.getElementById('success');
try {
btn.disabled = true;
loading.style.display = 'block';
error.style.display = 'none';
success.style.display = 'none';
// Check if MetaMask is available
if (typeof window.ethereum === 'undefined') {
throw new Error('Please install MetaMask or another Ethereum wallet');
}
// Request account access
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
if (accounts.length === 0) {
throw new Error('No wallet accounts found');
}
walletAddress = accounts[0];
console.log('Connected to wallet:', walletAddress);
// Step 1: Get challenge nonce
const challengeResponse = await fetch('/v1/auth/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
wallet: walletAddress,
purpose: 'api_key_generation',
namespace: namespace
})
});
if (!challengeResponse.ok) {
const errorData = await challengeResponse.json();
throw new Error(errorData.error || 'Failed to get challenge');
}
const challengeData = await challengeResponse.json();
const nonce = challengeData.nonce;
console.log('Received challenge nonce:', nonce);
// Step 2: Sign the nonce
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [nonce, walletAddress]
});
console.log('Signature obtained:', signature);
// Step 3: Get API key
const apiKeyResponse = await fetch('/v1/auth/api-key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
wallet: walletAddress,
nonce: nonce,
signature: signature,
namespace: namespace
})
});
if (!apiKeyResponse.ok) {
const errorData = await apiKeyResponse.json();
throw new Error(errorData.error || 'Failed to get API key');
}
const apiKeyData = await apiKeyResponse.json();
console.log('API key received:', apiKeyData);
loading.style.display = 'none';
success.innerHTML = '✅ Authentication successful! Redirecting...';
success.style.display = 'block';
// Redirect to callback URL with credentials
const params = new URLSearchParams({
api_key: apiKeyData.api_key,
namespace: apiKeyData.namespace,
wallet: apiKeyData.wallet,
plan: apiKeyData.plan || 'free'
});
const redirectURL = callbackURL + '?' + params.toString();
console.log('Redirecting to:', redirectURL);
setTimeout(() => {
window.location.href = redirectURL;
}, 1500);
} catch (err) {
console.error('Authentication error:', err);
loading.style.display = 'none';
error.innerHTML = '❌ ' + err.message;
error.style.display = 'block';
btn.disabled = false;
}
}
// Auto-detect if wallet is already connected
window.addEventListener('load', async () => {
if (typeof window.ethereum !== 'undefined') {
try {
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
if (accounts.length > 0) {
const btn = document.getElementById('connectBtn');
btn.innerHTML = '🔗 Continue with ' + accounts[0].slice(0, 6) + '...' + accounts[0].slice(-4);
}
} catch (err) {
console.log('Could not get accounts:', err);
}
}
});
</script>
</body>
</html>`, ns, callbackURL, ns)
fmt.Fprint(w, html)
}
// logoutHandler revokes refresh tokens. If a refresh_token is provided, it will
// be revoked. If all=true is provided (and the request is authenticated via JWT),
// all tokens for the JWT subject within the namespace are revoked.
func (g *Gateway) logoutHandler(w http.ResponseWriter, r *http.Request) {
if g.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req struct {
RefreshToken string `json:"refresh_token"`
Namespace string `json:"namespace"`
All bool `json:"all"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
ns := strings.TrimSpace(req.Namespace)
if ns == "" {
ns = strings.TrimSpace(g.cfg.ClientNamespace)
if ns == "" {
ns = "default"
}
}
ctx := r.Context()
db := g.client.Database()
nsID, err := g.resolveNamespaceID(ctx, ns)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if strings.TrimSpace(req.RefreshToken) != "" {
// Revoke specific token
if _, err := db.Query(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE namespace_id = ? AND token = ? AND revoked_at IS NULL", nsID, req.RefreshToken); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "revoked": 1})
return
}
if req.All {
// Require JWT to identify subject
var subject string
if v := ctx.Value(ctxKeyJWT); v != nil {
if claims, ok := v.(*jwtClaims); ok && claims != nil {
subject = strings.TrimSpace(claims.Sub)
}
}
if subject == "" {
writeError(w, http.StatusUnauthorized, "jwt required for all=true")
return
}
if _, err := db.Query(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE namespace_id = ? AND subject = ? AND revoked_at IS NULL", nsID, subject); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "revoked": "all"})
return
}
writeError(w, http.StatusBadRequest, "nothing to revoke: provide refresh_token or all=true")
}