package auth import ( "context" "fmt" "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 { // 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 // Required determines whether authentication is mandatory. // If true, requests without valid auth are rejected. // If false, unauthenticated requests are allowed (pubkey will be empty). // Default: false Required bool // ValidatePayload checks the payload hash tag if present. // Default: false ValidatePayload bool // AllowedNpubs is an optional whitelist of allowed pubkeys (hex format). // Config accepts npub format only, normalized to hex at load time. // If nil or empty, all valid signatures are accepted. // Default: nil (allow all) AllowedNpubs []string // SkipMethods is a list of gRPC methods that bypass authentication. // Useful for public endpoints like health checks or relay info. // Example: []string{"/nostr.v1.NostrRelay/QueryEvents"} // Default: nil (authenticate all methods) SkipMethods []string } // DefaultInterceptorOptions returns the default configuration. func DefaultInterceptorOptions() *InterceptorOptions { return &InterceptorOptions{ TimestampWindow: 60, Required: false, ValidatePayload: false, AllowedNpubs: nil, 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) } // Extract and validate auth pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) if err != nil { if opts.Required { return nil, status.Error(codes.Unauthenticated, err.Error()) } // Auth not required, continue without pubkey return handler(ctx, req) } // 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) } // Extract and validate auth ctx := ss.Context() pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) if err != nil { if opts.Required { return status.Error(codes.Unauthenticated, err.Error()) } // Auth not required, continue without pubkey return handler(srv, ss) } // 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) // Check whitelist if configured (all values are already normalized to hex) if len(opts.AllowedNpubs) > 0 { if !contains(opts.AllowedNpubs, pubkey) { return "", fmt.Errorf("pubkey not in whitelist") } } return pubkey, nil } // 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 }