summaryrefslogtreecommitdiffstats
path: root/internal/auth
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-14 10:11:16 -0800
committerbndw <ben@bdw.to>2026-02-14 10:11:16 -0800
commit606e0a3329a3534a00889eee19c25e7d432f7d2d (patch)
tree526b1419eaa6b9b91126adbfa5990ec47f5d3a07 /internal/auth
parenta90009e6b887a8a7ca67f49566af2caffb807776 (diff)
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.
Diffstat (limited to 'internal/auth')
-rw-r--r--internal/auth/README.md37
-rw-r--r--internal/auth/auth_test.go9
-rw-r--r--internal/auth/interceptor.go118
3 files changed, 103 insertions, 61 deletions
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 (
142 142
143// Create auth options 143// Create auth options
144authOpts := &auth.InterceptorOptions{ 144authOpts := &auth.InterceptorOptions{
145 Read: auth.OperationAuthConfig{
146 Enabled: true, // Require auth for reads
147 AllowedNpubs: nil, // Accept any valid signature
148 },
149 Write: auth.OperationAuthConfig{
150 Enabled: true,
151 AllowedNpubs: []string{"hex-pubkey-1", "hex-pubkey-2"}, // Whitelist
152 },
145 TimestampWindow: 60, // Accept events within 60 seconds 153 TimestampWindow: 60, // Accept events within 60 seconds
146 Required: true, // Reject unauthenticated requests
147} 154}
148 155
149// Create gRPC server with interceptors 156// Create gRPC server with interceptors
@@ -206,23 +213,25 @@ authOpts := &auth.InterceptorOptions{
206 213
207### InterceptorOptions 214### InterceptorOptions
208 215
216- **`Read`**: Authentication config for read operations (Subscribe, QueryEvents, CountEvents)
217 - **`Enabled`**: false = no auth (allow all), true = auth required
218 - **`AllowedNpubs`**: Optional whitelist (hex format, normalized from npub in config)
219 - If `Enabled=false`: no auth required
220 - If `Enabled=true && AllowedNpubs=[]`: auth required, any valid signature accepted
221 - If `Enabled=true && AllowedNpubs=[...]`: auth required, only whitelisted npubs accepted
222
223- **`Write`**: Authentication config for write operations (PublishEvent, PublishBatch)
224 - Same structure as `Read`
225
209- **`TimestampWindow`**: Maximum age of events in seconds (default: 60) 226- **`TimestampWindow`**: Maximum age of events in seconds (default: 60)
210- **`Required`**: Whether to reject unauthenticated requests (default: false)
211- **`ValidatePayload`**: Whether to verify payload hash when present (default: false) 227- **`ValidatePayload`**: Whether to verify payload hash when present (default: false)
212- **`AllowedNpubsRead`**: Optional whitelist of allowed pubkeys for read operations (nil = allow all) 228- **`SkipMethods`**: List of methods that bypass auth (e.g., health checks)
213 - Config accepts npub format only (human-readable bech32)
214 - Automatically normalized to hex format (computer-readable) at config load time
215 - Controls access to Query, Get, List, Subscribe, and other read methods
216- **`AllowedNpubsWrite`**: Optional whitelist of allowed pubkeys for write operations (nil = allow all)
217 - Config accepts npub format only (human-readable bech32)
218 - Automatically normalized to hex format (computer-readable) at config load time
219 - Controls access to Publish, Delete, Create, Update, and other write methods
220 229
221**Access Control Patterns:** 230**Access Control Patterns:**
222- **Public relay**: Set `AllowedNpubsWrite` (only some can publish), leave `AllowedNpubsRead` empty (everyone can read) 231- **Public relay**: `Read.Enabled=false`, `Write.Enabled=true` with whitelist
223- **Private relay**: Set both lists (restricted read and write access) 232- **Private relay**: Both `Enabled=true` with whitelists
224- **Open relay**: Leave both empty (everyone can read and write) 233- **Open relay**: Both `Enabled=false`
225- **Read-only relay**: Set `AllowedNpubsRead`, block all writes 234- **Authenticated reads, open writes**: `Read.Enabled=true`, `Write.Enabled=false`
226 235
227### NostrCredentials Options 236### NostrCredentials Options
228 237
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) {
243 ctx := metadata.NewIncomingContext(context.Background(), md) 243 ctx := metadata.NewIncomingContext(context.Background(), md)
244 244
245 opts := &InterceptorOptions{ 245 opts := &InterceptorOptions{
246 Read: OperationAuthConfig{
247 Enabled: true,
248 AllowedNpubs: nil,
249 },
250 Write: OperationAuthConfig{
251 Enabled: true,
252 AllowedNpubs: nil,
253 },
246 TimestampWindow: 60, 254 TimestampWindow: 60,
247 Required: true,
248 } 255 }
249 256
250 pubkey, err := validateAuthFromContext(ctx, "/test.Service/Method", opts) 257 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 (
21 21
22// InterceptorOptions configures the authentication interceptor behavior. 22// InterceptorOptions configures the authentication interceptor behavior.
23type InterceptorOptions struct { 23type InterceptorOptions struct {
24 // Read configures authentication for read operations (Subscribe, QueryEvents, etc.)
25 Read OperationAuthConfig
26
27 // Write configures authentication for write operations (PublishEvent, PublishBatch)
28 Write OperationAuthConfig
29
24 // TimestampWindow is the maximum age of auth events in seconds. 30 // TimestampWindow is the maximum age of auth events in seconds.
25 // Events older than this are rejected to prevent replay attacks. 31 // Events older than this are rejected to prevent replay attacks.
26 // Default: 60 seconds 32 // Default: 60 seconds
27 TimestampWindow int64 33 TimestampWindow int64
28 34
29 // Required determines whether authentication is mandatory.
30 // If true, requests without valid auth are rejected.
31 // If false, unauthenticated requests are allowed (pubkey will be empty).
32 // Default: false
33 Required bool
34
35 // ValidatePayload checks the payload hash tag if present. 35 // ValidatePayload checks the payload hash tag if present.
36 // Default: false 36 // Default: false
37 ValidatePayload bool 37 ValidatePayload bool
38 38
39 // AllowedNpubsRead is an optional whitelist of allowed pubkeys for read operations (hex format).
40 // Config accepts npub format only, normalized to hex at load time.
41 // If nil or empty, all valid signatures are accepted for reads.
42 // Default: nil (allow all)
43 AllowedNpubsRead []string
44
45 // AllowedNpubsWrite is an optional whitelist of allowed pubkeys for write operations (hex format).
46 // Config accepts npub format only, normalized to hex at load time.
47 // If nil or empty, all valid signatures are accepted for writes.
48 // Default: nil (allow all)
49 AllowedNpubsWrite []string
50
51 // SkipMethods is a list of gRPC methods that bypass authentication. 39 // SkipMethods is a list of gRPC methods that bypass authentication.
52 // Useful for public endpoints like health checks or relay info. 40 // Useful for public endpoints like health checks or relay info.
53 // Example: []string{"/nostr.v1.NostrRelay/QueryEvents"} 41 // Example: []string{"/grpc.health.v1.Health/Check"}
54 // Default: nil (authenticate all methods) 42 // Default: nil (authenticate all methods)
55 SkipMethods []string 43 SkipMethods []string
56} 44}
57 45
46// OperationAuthConfig configures auth for a specific operation type.
47type OperationAuthConfig struct {
48 // Enabled determines if auth is required.
49 // false = no auth, allow all
50 // true = auth required
51 Enabled bool
52
53 // AllowedNpubs is an optional whitelist (hex format, normalized from npub at config load).
54 // If Enabled=true && AllowedNpubs=[]: any valid signature accepted
55 // If Enabled=true && AllowedNpubs=[...]: only whitelisted npubs accepted
56 AllowedNpubs []string
57}
58
58// DefaultInterceptorOptions returns the default configuration. 59// DefaultInterceptorOptions returns the default configuration.
59func DefaultInterceptorOptions() *InterceptorOptions { 60func DefaultInterceptorOptions() *InterceptorOptions {
60 return &InterceptorOptions{ 61 return &InterceptorOptions{
61 TimestampWindow: 60, 62 Read: OperationAuthConfig{
62 Required: false, 63 Enabled: false,
63 ValidatePayload: false, 64 AllowedNpubs: nil,
64 AllowedNpubsRead: nil, 65 },
65 AllowedNpubsWrite: nil, 66 Write: OperationAuthConfig{
66 SkipMethods: nil, 67 Enabled: false,
68 AllowedNpubs: nil,
69 },
70 TimestampWindow: 60,
71 ValidatePayload: false,
72 SkipMethods: nil,
67 } 73 }
68} 74}
69 75
@@ -79,14 +85,23 @@ func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor
79 return handler(ctx, req) 85 return handler(ctx, req)
80 } 86 }
81 87
88 // Check if auth is required for this operation type
89 var authRequired bool
90 if isWriteMethod(info.FullMethod) {
91 authRequired = opts.Write.Enabled
92 } else {
93 authRequired = opts.Read.Enabled
94 }
95
96 // If auth not required, skip validation
97 if !authRequired {
98 return handler(ctx, req)
99 }
100
82 // Extract and validate auth 101 // Extract and validate auth
83 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) 102 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts)
84 if err != nil { 103 if err != nil {
85 if opts.Required { 104 return nil, status.Error(codes.Unauthenticated, err.Error())
86 return nil, status.Error(codes.Unauthenticated, err.Error())
87 }
88 // Auth not required, continue without pubkey
89 return handler(ctx, req)
90 } 105 }
91 106
92 // Add pubkey to context for handlers 107 // Add pubkey to context for handlers
@@ -108,15 +123,24 @@ func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerIntercept
108 return handler(srv, ss) 123 return handler(srv, ss)
109 } 124 }
110 125
126 // Check if auth is required for this operation type
127 var authRequired bool
128 if isWriteMethod(info.FullMethod) {
129 authRequired = opts.Write.Enabled
130 } else {
131 authRequired = opts.Read.Enabled
132 }
133
134 // If auth not required, skip validation
135 if !authRequired {
136 return handler(srv, ss)
137 }
138
111 // Extract and validate auth 139 // Extract and validate auth
112 ctx := ss.Context() 140 ctx := ss.Context()
113 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts) 141 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts)
114 if err != nil { 142 if err != nil {
115 if opts.Required { 143 return status.Error(codes.Unauthenticated, err.Error())
116 return status.Error(codes.Unauthenticated, err.Error())
117 }
118 // Auth not required, continue without pubkey
119 return handler(srv, ss)
120 } 144 }
121 145
122 // Wrap stream with authenticated context 146 // Wrap stream with authenticated context
@@ -177,23 +201,25 @@ func validateAuthFromContext(ctx context.Context, method string, opts *Intercept
177 // Extract pubkey 201 // Extract pubkey
178 pubkey := ExtractPubkey(event) 202 pubkey := ExtractPubkey(event)
179 203
180 // Check whitelist based on operation type (all values are already normalized to hex) 204 // Get the operation config based on method type
205 var opConfig OperationAuthConfig
181 if isWriteMethod(method) { 206 if isWriteMethod(method) {
182 // Write operation - check write allowlist 207 opConfig = opts.Write
183 if len(opts.AllowedNpubsWrite) > 0 {
184 if !contains(opts.AllowedNpubsWrite, pubkey) {
185 return "", fmt.Errorf("pubkey not authorized for write operations")
186 }
187 }
188 } else { 208 } else {
189 // Read operation - check read allowlist 209 opConfig = opts.Read
190 if len(opts.AllowedNpubsRead) > 0 { 210 }
191 if !contains(opts.AllowedNpubsRead, pubkey) { 211
192 return "", fmt.Errorf("pubkey not authorized for read operations") 212 // Check whitelist if configured
213 if len(opConfig.AllowedNpubs) > 0 {
214 if !contains(opConfig.AllowedNpubs, pubkey) {
215 if isWriteMethod(method) {
216 return "", fmt.Errorf("pubkey not authorized for write operations")
193 } 217 }
218 return "", fmt.Errorf("pubkey not authorized for read operations")
194 } 219 }
195 } 220 }
196 221
222 // No whitelist or pubkey in whitelist - allow
197 return pubkey, nil 223 return pubkey, nil
198} 224}
199 225