package auth import ( "context" "fmt" "strings" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) // contextKey is a custom type for context keys to avoid collisions. type contextKey string const ( // pubkeyContextKey is the key for storing the authenticated pubkey in context. pubkeyContextKey contextKey = "nostr-pubkey" ) // InterceptorOptions configures the authentication interceptor behavior. type InterceptorOptions struct { // Read configures authentication for read operations (Subscribe, QueryEvents, etc.) Read OperationAuthConfig // Write configures authentication for write operations (PublishEvent, PublishBatch) Write OperationAuthConfig // TimestampWindow is the maximum age of auth events in seconds. // Events older than this are rejected to prevent replay attacks. // Default: 60 seconds TimestampWindow int64 // ValidatePayload checks the payload hash tag if present. // Default: false ValidatePayload bool // SkipMethods is a list of gRPC methods that bypass authentication. // Useful for public endpoints like health checks or relay info. // Example: []string{"/grpc.health.v1.Health/Check"} // Default: nil (authenticate all methods) SkipMethods []string } // OperationAuthConfig configures auth for a specific operation type. type OperationAuthConfig struct { // Enabled determines if auth is required. // false = no auth, allow all // true = auth required Enabled bool // AllowedNpubs is an optional whitelist (hex format, normalized from npub at config load). // If Enabled=true && AllowedNpubs=[]: any valid signature accepted // If Enabled=true && AllowedNpubs=[...]: only whitelisted npubs accepted AllowedNpubs []string } // DefaultInterceptorOptions returns the default configuration. func DefaultInterceptorOptions() *InterceptorOptions { return &InterceptorOptions{ Read: OperationAuthConfig{ Enabled: false, AllowedNpubs: nil, }, Write: OperationAuthConfig{ Enabled: false, AllowedNpubs: nil, }, TimestampWindow: 60, ValidatePayload: false, SkipMethods: nil, } } // NostrUnaryInterceptor creates a gRPC unary interceptor for NIP-98 authentication. func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor { if opts == nil { opts = DefaultInterceptorOptions() } return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { // Check if this method should skip auth if shouldSkipAuth(info.FullMethod, opts.SkipMethods) { return handler(ctx, req) } // Check if auth is required for this operation type var authRequired bool if isWriteMethod(info.FullMethod) { authRequired = opts.Write.Enabled } else { authRequired = opts.Read.Enabled } // If auth not required, skip validation if !authRequired { return handler(ctx, req) } // Extract and validate auth pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) if err != nil { return nil, status.Error(codes.Unauthenticated, err.Error()) } // Add pubkey to context for handlers ctx = context.WithValue(ctx, pubkeyContextKey, pubkey) return handler(ctx, req) } } // NostrStreamInterceptor creates a gRPC stream interceptor for NIP-98 authentication. func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerInterceptor { if opts == nil { opts = DefaultInterceptorOptions() } return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { // Check if this method should skip auth if shouldSkipAuth(info.FullMethod, opts.SkipMethods) { return handler(srv, ss) } // Check if auth is required for this operation type var authRequired bool if isWriteMethod(info.FullMethod) { authRequired = opts.Write.Enabled } else { authRequired = opts.Read.Enabled } // If auth not required, skip validation if !authRequired { return handler(srv, ss) } // Extract and validate auth ctx := ss.Context() pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) if err != nil { return status.Error(codes.Unauthenticated, err.Error()) } // Wrap stream with authenticated context wrappedStream := &authenticatedStream{ ServerStream: ss, ctx: context.WithValue(ctx, pubkeyContextKey, pubkey), } return handler(srv, wrappedStream) } } // authenticatedStream wraps a ServerStream with an authenticated context. type authenticatedStream struct { grpc.ServerStream ctx context.Context } func (s *authenticatedStream) Context() context.Context { return s.ctx } // validateAuthFromContext extracts and validates the NIP-98 auth event from the context. func validateAuthFromContext(ctx context.Context, method string, opts *InterceptorOptions) (string, error) { // Extract metadata from context md, ok := metadata.FromIncomingContext(ctx) if !ok { return "", fmt.Errorf("missing metadata") } // Get authorization header authHeaders := md.Get("authorization") if len(authHeaders) == 0 { return "", fmt.Errorf("missing authorization header") } authHeader := authHeaders[0] // Parse the auth event event, err := ParseAuthHeader(authHeader) if err != nil { return "", fmt.Errorf("invalid auth header: %w", err) } // Validate the event validationOpts := ValidationOptions{ TimestampWindow: opts.TimestampWindow, ValidatePayload: opts.ValidatePayload, ExpectedMethod: "POST", // gRPC always uses POST // Note: We don't validate URI here because the full URI isn't easily // available in the interceptor context. The method name is validated instead. } if err := ValidateAuthEvent(event, validationOpts); err != nil { return "", fmt.Errorf("invalid auth event: %w", err) } // Extract pubkey pubkey := ExtractPubkey(event) // Get the operation config based on method type var opConfig OperationAuthConfig if isWriteMethod(method) { opConfig = opts.Write } else { opConfig = opts.Read } // Check whitelist if configured if len(opConfig.AllowedNpubs) > 0 { if !contains(opConfig.AllowedNpubs, pubkey) { if isWriteMethod(method) { return "", fmt.Errorf("pubkey not authorized for write operations") } return "", fmt.Errorf("pubkey not authorized for read operations") } } // No whitelist or pubkey in whitelist - allow return pubkey, nil } // isWriteMethod determines if a gRPC method is a write operation. // Write: PublishEvent, PublishBatch // Read: Subscribe, Unsubscribe, QueryEvents, CountEvents func isWriteMethod(method string) bool { return strings.Contains(method, "/PublishEvent") || strings.Contains(method, "/PublishBatch") } // shouldSkipAuth checks if a method should bypass authentication. func shouldSkipAuth(method string, skipMethods []string) bool { for _, skip := range skipMethods { if skip == method { return true } } return false } // contains checks if a slice contains a string. func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } // PubkeyFromContext retrieves the authenticated pubkey from the context. // Returns the pubkey and true if authentication was successful, or empty string and false otherwise. func PubkeyFromContext(ctx context.Context) (string, bool) { pubkey, ok := ctx.Value(pubkeyContextKey).(string) return pubkey, ok } // RequireAuth is a helper that extracts the pubkey and returns an error if not authenticated. func RequireAuth(ctx context.Context) (string, error) { pubkey, ok := PubkeyFromContext(ctx) if !ok || pubkey == "" { return "", status.Error(codes.Unauthenticated, "authentication required") } return pubkey, nil }