package auth import ( "encoding/base64" "encoding/json" "fmt" "strings" "time" "northwest.io/muxstr/internal/nostr" ) // ValidationOptions configures how NIP-98 events are validated. type ValidationOptions struct { // TimestampWindow is the maximum age of events in seconds TimestampWindow int64 // ValidatePayload checks the payload hash if present ValidatePayload bool // ExpectedURI is the URI that should match the 'u' tag ExpectedURI string // ExpectedMethod is the method that should match the 'method' tag ExpectedMethod string // PayloadHash is the expected payload hash (if ValidatePayload is true) PayloadHash string } // ParseAuthHeader extracts and decodes a NIP-98 event from an Authorization header. // Expected format: "Nostr " func ParseAuthHeader(header string) (*nostr.Event, error) { if header == "" { return nil, fmt.Errorf("empty authorization header") } // Check for "Nostr " prefix if !strings.HasPrefix(header, "Nostr ") { return nil, fmt.Errorf("invalid authorization header: must start with 'Nostr '") } // Extract base64 part encoded := strings.TrimPrefix(header, "Nostr ") if encoded == "" { return nil, fmt.Errorf("empty authorization token") } // Decode base64 decoded, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return nil, fmt.Errorf("invalid base64 encoding: %w", err) } // Unmarshal event var event nostr.Event if err := json.Unmarshal(decoded, &event); err != nil { return nil, fmt.Errorf("invalid event JSON: %w", err) } return &event, nil } // ValidateAuthEvent validates a NIP-98 auth event according to the spec. func ValidateAuthEvent(event *nostr.Event, opts ValidationOptions) error { // Check event kind if event.Kind != 27235 { return fmt.Errorf("invalid event kind: expected 27235, got %d", event.Kind) } // Verify signature if !event.Verify() { return fmt.Errorf("invalid event signature") } // Check timestamp (prevent replay attacks) now := time.Now().Unix() age := now - event.CreatedAt if age < 0 { return fmt.Errorf("event timestamp is in the future") } if opts.TimestampWindow > 0 && age > opts.TimestampWindow { return fmt.Errorf("event too old: %d seconds (max %d)", age, opts.TimestampWindow) } // Validate 'u' tag (URL) if opts.ExpectedURI != "" { uTag := event.Tags.Find("u") if uTag == nil { return fmt.Errorf("missing 'u' tag in auth event") } eventURI := uTag.Value() if eventURI != opts.ExpectedURI { return fmt.Errorf("URI mismatch: expected %s, got %s", opts.ExpectedURI, eventURI) } } // Validate 'method' tag if opts.ExpectedMethod != "" { methodTag := event.Tags.Find("method") if methodTag == nil { return fmt.Errorf("missing 'method' tag in auth event") } eventMethod := methodTag.Value() if eventMethod != opts.ExpectedMethod { return fmt.Errorf("method mismatch: expected %s, got %s", opts.ExpectedMethod, eventMethod) } } // Validate payload hash if requested if opts.ValidatePayload && opts.PayloadHash != "" { payloadTag := event.Tags.Find("payload") if payloadTag == nil { return fmt.Errorf("missing 'payload' tag in auth event") } eventHash := payloadTag.Value() if eventHash != opts.PayloadHash { return fmt.Errorf("payload hash mismatch") } } return nil } // ExtractPubkey returns the pubkey from a validated auth event. func ExtractPubkey(event *nostr.Event) string { return event.PubKey }