From 606e0a3329a3534a00889eee19c25e7d432f7d2d Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 14 Feb 2026 10:11:16 -0800 Subject: refactor: restructure auth config for better UX Changed from flat structure to hierarchical read/write config: Before: auth: enabled: bool required: bool allowed_npubs_read: [] allowed_npubs_write: [] After: auth: read: enabled: bool allowed_npubs: [] write: enabled: bool allowed_npubs: [] Three states per operation: - enabled=false: no auth, allow all - enabled=true, allowed_npubs=[]: auth required, any valid signature - enabled=true, allowed_npubs=[...]: auth required, whitelist only Much clearer semantics and easier to reason about. --- internal/auth/README.md | 37 +++++++++----- internal/auth/auth_test.go | 9 +++- internal/auth/interceptor.go | 118 ++++++++++++++++++++++++++----------------- 3 files changed, 103 insertions(+), 61 deletions(-) (limited to 'internal/auth') diff --git a/internal/auth/README.md b/internal/auth/README.md index 366e110..de37010 100644 --- a/internal/auth/README.md +++ b/internal/auth/README.md @@ -142,8 +142,15 @@ import ( // Create auth options authOpts := &auth.InterceptorOptions{ + Read: auth.OperationAuthConfig{ + Enabled: true, // Require auth for reads + AllowedNpubs: nil, // Accept any valid signature + }, + Write: auth.OperationAuthConfig{ + Enabled: true, + AllowedNpubs: []string{"hex-pubkey-1", "hex-pubkey-2"}, // Whitelist + }, TimestampWindow: 60, // Accept events within 60 seconds - Required: true, // Reject unauthenticated requests } // Create gRPC server with interceptors @@ -206,23 +213,25 @@ authOpts := &auth.InterceptorOptions{ ### InterceptorOptions +- **`Read`**: Authentication config for read operations (Subscribe, QueryEvents, CountEvents) + - **`Enabled`**: false = no auth (allow all), true = auth required + - **`AllowedNpubs`**: Optional whitelist (hex format, normalized from npub in config) + - If `Enabled=false`: no auth required + - If `Enabled=true && AllowedNpubs=[]`: auth required, any valid signature accepted + - If `Enabled=true && AllowedNpubs=[...]`: auth required, only whitelisted npubs accepted + +- **`Write`**: Authentication config for write operations (PublishEvent, PublishBatch) + - Same structure as `Read` + - **`TimestampWindow`**: Maximum age of events in seconds (default: 60) -- **`Required`**: Whether to reject unauthenticated requests (default: false) - **`ValidatePayload`**: Whether to verify payload hash when present (default: false) -- **`AllowedNpubsRead`**: Optional whitelist of allowed pubkeys for read operations (nil = allow all) - - Config accepts npub format only (human-readable bech32) - - Automatically normalized to hex format (computer-readable) at config load time - - Controls access to Query, Get, List, Subscribe, and other read methods -- **`AllowedNpubsWrite`**: Optional whitelist of allowed pubkeys for write operations (nil = allow all) - - Config accepts npub format only (human-readable bech32) - - Automatically normalized to hex format (computer-readable) at config load time - - Controls access to Publish, Delete, Create, Update, and other write methods +- **`SkipMethods`**: List of methods that bypass auth (e.g., health checks) **Access Control Patterns:** -- **Public relay**: Set `AllowedNpubsWrite` (only some can publish), leave `AllowedNpubsRead` empty (everyone can read) -- **Private relay**: Set both lists (restricted read and write access) -- **Open relay**: Leave both empty (everyone can read and write) -- **Read-only relay**: Set `AllowedNpubsRead`, block all writes +- **Public relay**: `Read.Enabled=false`, `Write.Enabled=true` with whitelist +- **Private relay**: Both `Enabled=true` with whitelists +- **Open relay**: Both `Enabled=false` +- **Authenticated reads, open writes**: `Read.Enabled=true`, `Write.Enabled=false` ### NostrCredentials Options diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index d5f3257..bcbb4a3 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -243,8 +243,15 @@ func TestValidateAuthFromContext(t *testing.T) { ctx := metadata.NewIncomingContext(context.Background(), md) opts := &InterceptorOptions{ + Read: OperationAuthConfig{ + Enabled: true, + AllowedNpubs: nil, + }, + Write: OperationAuthConfig{ + Enabled: true, + AllowedNpubs: nil, + }, TimestampWindow: 60, - Required: true, } pubkey, err := validateAuthFromContext(ctx, "/test.Service/Method", opts) diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go index 149cc01..d394102 100644 --- a/internal/auth/interceptor.go +++ b/internal/auth/interceptor.go @@ -21,49 +21,55 @@ const ( // 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 - // 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 - // AllowedNpubsRead is an optional whitelist of allowed pubkeys for read operations (hex format). - // Config accepts npub format only, normalized to hex at load time. - // If nil or empty, all valid signatures are accepted for reads. - // Default: nil (allow all) - AllowedNpubsRead []string - - // AllowedNpubsWrite is an optional whitelist of allowed pubkeys for write operations (hex format). - // Config accepts npub format only, normalized to hex at load time. - // If nil or empty, all valid signatures are accepted for writes. - // Default: nil (allow all) - AllowedNpubsWrite []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"} + // 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{ - TimestampWindow: 60, - Required: false, - ValidatePayload: false, - AllowedNpubsRead: nil, - AllowedNpubsWrite: nil, - SkipMethods: nil, + Read: OperationAuthConfig{ + Enabled: false, + AllowedNpubs: nil, + }, + Write: OperationAuthConfig{ + Enabled: false, + AllowedNpubs: nil, + }, + TimestampWindow: 60, + ValidatePayload: false, + SkipMethods: nil, } } @@ -79,14 +85,23 @@ func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor 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 { - if opts.Required { - return nil, status.Error(codes.Unauthenticated, err.Error()) - } - // Auth not required, continue without pubkey - return handler(ctx, req) + return nil, status.Error(codes.Unauthenticated, err.Error()) } // Add pubkey to context for handlers @@ -108,15 +123,24 @@ func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerIntercept 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 { - if opts.Required { - return status.Error(codes.Unauthenticated, err.Error()) - } - // Auth not required, continue without pubkey - return handler(srv, ss) + return status.Error(codes.Unauthenticated, err.Error()) } // Wrap stream with authenticated context @@ -177,23 +201,25 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept // Extract pubkey pubkey := ExtractPubkey(event) - // Check whitelist based on operation type (all values are already normalized to hex) + // Get the operation config based on method type + var opConfig OperationAuthConfig if isWriteMethod(method) { - // Write operation - check write allowlist - if len(opts.AllowedNpubsWrite) > 0 { - if !contains(opts.AllowedNpubsWrite, pubkey) { - return "", fmt.Errorf("pubkey not authorized for write operations") - } - } + opConfig = opts.Write } else { - // Read operation - check read allowlist - if len(opts.AllowedNpubsRead) > 0 { - if !contains(opts.AllowedNpubsRead, pubkey) { - return "", fmt.Errorf("pubkey not authorized for read operations") + 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 } -- cgit v1.2.3