verify.go raw

   1  package handler
   2  
   3  import (
   4  	"encoding/json"
   5  	"io"
   6  	"log"
   7  	"net/http"
   8  	"net/url"
   9  	"time"
  10  
  11  	"next.orly.dev/pkg/nostr/encoders/event"
  12  	"next.orly.dev/pkg/nostr/encoders/hex"
  13  )
  14  
  15  type VerifyResponse struct {
  16  	Success     bool   `json:"success"`
  17  	RedirectURL string `json:"redirect_url,omitempty"`
  18  	Error       string `json:"error,omitempty"`
  19  }
  20  
  21  func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
  22  	w.Header().Set("Content-Type", "application/json")
  23  
  24  	// Read the raw JSON body — event.E.Unmarshal handles hex→binary conversion
  25  	body, err := io.ReadAll(r.Body)
  26  	if err != nil {
  27  		json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid request body"})
  28  		return
  29  	}
  30  
  31  	var ev event.E
  32  	if _, err := ev.Unmarshal(body); err != nil {
  33  		json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid event JSON"})
  34  		return
  35  	}
  36  
  37  	// Verify event kind (NIP-98 HTTP Auth)
  38  	if ev.Kind != 27235 {
  39  		json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid event kind, expected 27235"})
  40  		return
  41  	}
  42  
  43  	// Verify timestamp is within window
  44  	eventTime := time.Unix(ev.CreatedAt, 0)
  45  	if time.Since(eventTime) > h.cfg.Nostr.ChallengeTTL {
  46  		json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp too old"})
  47  		return
  48  	}
  49  	if eventTime.After(time.Now().Add(time.Minute)) {
  50  		json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp in future"})
  51  		return
  52  	}
  53  
  54  	// Verify signature
  55  	ok, err := ev.Verify()
  56  	if err != nil || !ok {
  57  		log.Printf("signature verification failed: %v", err)
  58  		json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid signature"})
  59  		return
  60  	}
  61  
  62  	// Extract challenge from tags
  63  	var challengeNonce string
  64  	if ev.Tags != nil {
  65  		for _, t := range *ev.Tags {
  66  			ss := t.ToSliceOfStrings()
  67  			if len(ss) >= 2 && ss[0] == "challenge" {
  68  				challengeNonce = ss[1]
  69  				break
  70  			}
  71  		}
  72  	}
  73  
  74  	if challengeNonce == "" {
  75  		json.NewEncoder(w).Encode(VerifyResponse{Error: "missing challenge tag"})
  76  		return
  77  	}
  78  
  79  	// Look up challenge
  80  	challenge, err := h.store.GetChallenge(challengeNonce)
  81  	if err != nil || challenge == nil {
  82  		json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid or expired challenge"})
  83  		return
  84  	}
  85  
  86  	// Delete challenge (single use)
  87  	h.store.DeleteChallenge(challengeNonce)
  88  
  89  	// Convert binary pubkey to hex string for storage
  90  	pubkeyHex := string(hex.Enc(ev.Pubkey))
  91  
  92  	// Create authorization code
  93  	authCode, err := h.store.CreateAuthCode(
  94  		challenge.ClientID,
  95  		challenge.RedirectURI,
  96  		pubkeyHex,
  97  		challenge.State,
  98  	)
  99  	if err != nil {
 100  		log.Printf("failed to create auth code: %v", err)
 101  		json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
 102  		return
 103  	}
 104  
 105  	// Build redirect URL
 106  	redirectURL, err := url.Parse(challenge.RedirectURI)
 107  	if err != nil {
 108  		log.Printf("invalid redirect URI: %v", err)
 109  		json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
 110  		return
 111  	}
 112  
 113  	q := redirectURL.Query()
 114  	q.Set("code", authCode.Code)
 115  	if challenge.State != "" {
 116  		q.Set("state", challenge.State)
 117  	}
 118  	redirectURL.RawQuery = q.Encode()
 119  
 120  	json.NewEncoder(w).Encode(VerifyResponse{
 121  		Success:     true,
 122  		RedirectURL: redirectURL.String(),
 123  	})
 124  }
 125