- Add Nostr OAuth2 server with NIP-98 authentication support - Implement OAuth2 authorization and token endpoints - Add .well-known/openid-configuration discovery endpoint - Include Dockerfile for containerized deployment - Add Claude Code release command for version management - Create example configuration file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
135 lines
3.3 KiB
Go
135 lines
3.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/nbd-wtf/go-nostr"
|
|
)
|
|
|
|
type VerifyRequest struct {
|
|
ID string `json:"id"`
|
|
PubKey string `json:"pubkey"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
Kind int `json:"kind"`
|
|
Tags [][]string `json:"tags"`
|
|
Content string `json:"content"`
|
|
Sig string `json:"sig"`
|
|
}
|
|
|
|
type VerifyResponse struct {
|
|
Success bool `json:"success"`
|
|
RedirectURL string `json:"redirect_url,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
var req VerifyRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
// Convert to go-nostr Event for verification
|
|
event := nostr.Event{
|
|
ID: req.ID,
|
|
PubKey: req.PubKey,
|
|
CreatedAt: nostr.Timestamp(req.CreatedAt),
|
|
Kind: req.Kind,
|
|
Tags: make(nostr.Tags, len(req.Tags)),
|
|
Content: req.Content,
|
|
Sig: req.Sig,
|
|
}
|
|
|
|
for i, tag := range req.Tags {
|
|
event.Tags[i] = tag
|
|
}
|
|
|
|
// Verify event kind
|
|
if event.Kind != 27235 {
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid event kind, expected 27235"})
|
|
return
|
|
}
|
|
|
|
// Verify timestamp is within window
|
|
eventTime := time.Unix(int64(event.CreatedAt), 0)
|
|
if time.Since(eventTime) > h.cfg.Nostr.ChallengeTTL {
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp too old"})
|
|
return
|
|
}
|
|
if eventTime.After(time.Now().Add(time.Minute)) {
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "event timestamp in future"})
|
|
return
|
|
}
|
|
|
|
// Verify signature
|
|
ok, err := event.CheckSignature()
|
|
if err != nil || !ok {
|
|
log.Printf("signature verification failed: %v", err)
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid signature"})
|
|
return
|
|
}
|
|
|
|
// Extract challenge from tags
|
|
var challengeNonce string
|
|
for _, tag := range event.Tags {
|
|
if len(tag) >= 2 && tag[0] == "challenge" {
|
|
challengeNonce = tag[1]
|
|
break
|
|
}
|
|
}
|
|
|
|
if challengeNonce == "" {
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "missing challenge tag"})
|
|
return
|
|
}
|
|
|
|
// Look up challenge
|
|
challenge, err := h.store.GetChallenge(challengeNonce)
|
|
if err != nil || challenge == nil {
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "invalid or expired challenge"})
|
|
return
|
|
}
|
|
|
|
// Delete challenge (single use)
|
|
h.store.DeleteChallenge(challengeNonce)
|
|
|
|
// Create authorization code
|
|
authCode, err := h.store.CreateAuthCode(
|
|
challenge.ClientID,
|
|
challenge.RedirectURI,
|
|
event.PubKey,
|
|
challenge.State,
|
|
)
|
|
if err != nil {
|
|
log.Printf("failed to create auth code: %v", err)
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
|
|
return
|
|
}
|
|
|
|
// Build redirect URL
|
|
redirectURL, err := url.Parse(challenge.RedirectURI)
|
|
if err != nil {
|
|
log.Printf("invalid redirect URI: %v", err)
|
|
json.NewEncoder(w).Encode(VerifyResponse{Error: "internal error"})
|
|
return
|
|
}
|
|
|
|
q := redirectURL.Query()
|
|
q.Set("code", authCode.Code)
|
|
if challenge.State != "" {
|
|
q.Set("state", challenge.State)
|
|
}
|
|
redirectURL.RawQuery = q.Encode()
|
|
|
|
json.NewEncoder(w).Encode(VerifyResponse{
|
|
Success: true,
|
|
RedirectURL: redirectURL.String(),
|
|
})
|
|
}
|