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