summaryrefslogtreecommitdiffstats
path: root/internal/auth/interceptor.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/auth/interceptor.go')
-rw-r--r--internal/auth/interceptor.go215
1 files changed, 215 insertions, 0 deletions
diff --git a/internal/auth/interceptor.go b/internal/auth/interceptor.go
new file mode 100644
index 0000000..c055a15
--- /dev/null
+++ b/internal/auth/interceptor.go
@@ -0,0 +1,215 @@
1package auth
2
3import (
4 "context"
5 "fmt"
6
7 "google.golang.org/grpc"
8 "google.golang.org/grpc/codes"
9 "google.golang.org/grpc/metadata"
10 "google.golang.org/grpc/status"
11)
12
13// contextKey is a custom type for context keys to avoid collisions.
14type contextKey string
15
16const (
17 // pubkeyContextKey is the key for storing the authenticated pubkey in context.
18 pubkeyContextKey contextKey = "nostr-pubkey"
19)
20
21// InterceptorOptions configures the authentication interceptor behavior.
22type InterceptorOptions struct {
23 // TimestampWindow is the maximum age of auth events in seconds.
24 // Events older than this are rejected to prevent replay attacks.
25 // Default: 60 seconds
26 TimestampWindow int64
27
28 // Required determines whether authentication is mandatory.
29 // If true, requests without valid auth are rejected.
30 // If false, unauthenticated requests are allowed (pubkey will be empty).
31 // Default: false
32 Required bool
33
34 // ValidatePayload checks the payload hash tag if present.
35 // Default: false
36 ValidatePayload bool
37
38 // AllowedPubkeys is an optional whitelist of allowed pubkeys.
39 // If nil or empty, all valid signatures are accepted.
40 // Default: nil (allow all)
41 AllowedPubkeys []string
42
43 // SkipMethods is a list of gRPC methods that bypass authentication.
44 // Useful for public endpoints like health checks or relay info.
45 // Example: []string{"/nostr.v1.NostrRelay/QueryEvents"}
46 // Default: nil (authenticate all methods)
47 SkipMethods []string
48}
49
50// DefaultInterceptorOptions returns the default configuration.
51func DefaultInterceptorOptions() *InterceptorOptions {
52 return &InterceptorOptions{
53 TimestampWindow: 60,
54 Required: false,
55 ValidatePayload: false,
56 AllowedPubkeys: nil,
57 SkipMethods: nil,
58 }
59}
60
61// NostrUnaryInterceptor creates a gRPC unary interceptor for NIP-98 authentication.
62func NostrUnaryInterceptor(opts *InterceptorOptions) grpc.UnaryServerInterceptor {
63 if opts == nil {
64 opts = DefaultInterceptorOptions()
65 }
66
67 return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
68 // Check if this method should skip auth
69 if shouldSkipAuth(info.FullMethod, opts.SkipMethods) {
70 return handler(ctx, req)
71 }
72
73 // Extract and validate auth
74 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts)
75 if err != nil {
76 if opts.Required {
77 return nil, status.Error(codes.Unauthenticated, err.Error())
78 }
79 // Auth not required, continue without pubkey
80 return handler(ctx, req)
81 }
82
83 // Add pubkey to context for handlers
84 ctx = context.WithValue(ctx, pubkeyContextKey, pubkey)
85
86 return handler(ctx, req)
87 }
88}
89
90// NostrStreamInterceptor creates a gRPC stream interceptor for NIP-98 authentication.
91func NostrStreamInterceptor(opts *InterceptorOptions) grpc.StreamServerInterceptor {
92 if opts == nil {
93 opts = DefaultInterceptorOptions()
94 }
95
96 return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
97 // Check if this method should skip auth
98 if shouldSkipAuth(info.FullMethod, opts.SkipMethods) {
99 return handler(srv, ss)
100 }
101
102 // Extract and validate auth
103 ctx := ss.Context()
104 pubkey, err := validateAuthFromContext(ctx, info.FullMethod, opts)
105 if err != nil {
106 if opts.Required {
107 return status.Error(codes.Unauthenticated, err.Error())
108 }
109 // Auth not required, continue without pubkey
110 return handler(srv, ss)
111 }
112
113 // Wrap stream with authenticated context
114 wrappedStream := &authenticatedStream{
115 ServerStream: ss,
116 ctx: context.WithValue(ctx, pubkeyContextKey, pubkey),
117 }
118
119 return handler(srv, wrappedStream)
120 }
121}
122
123// authenticatedStream wraps a ServerStream with an authenticated context.
124type authenticatedStream struct {
125 grpc.ServerStream
126 ctx context.Context
127}
128
129func (s *authenticatedStream) Context() context.Context {
130 return s.ctx
131}
132
133// validateAuthFromContext extracts and validates the NIP-98 auth event from the context.
134func validateAuthFromContext(ctx context.Context, method string, opts *InterceptorOptions) (string, error) {
135 // Extract metadata from context
136 md, ok := metadata.FromIncomingContext(ctx)
137 if !ok {
138 return "", fmt.Errorf("missing metadata")
139 }
140
141 // Get authorization header
142 authHeaders := md.Get("authorization")
143 if len(authHeaders) == 0 {
144 return "", fmt.Errorf("missing authorization header")
145 }
146
147 authHeader := authHeaders[0]
148
149 // Parse the auth event
150 event, err := ParseAuthHeader(authHeader)
151 if err != nil {
152 return "", fmt.Errorf("invalid auth header: %w", err)
153 }
154
155 // Validate the event
156 validationOpts := ValidationOptions{
157 TimestampWindow: opts.TimestampWindow,
158 ValidatePayload: opts.ValidatePayload,
159 ExpectedMethod: "POST", // gRPC always uses POST
160 // Note: We don't validate URI here because the full URI isn't easily
161 // available in the interceptor context. The method name is validated instead.
162 }
163
164 if err := ValidateAuthEvent(event, validationOpts); err != nil {
165 return "", fmt.Errorf("invalid auth event: %w", err)
166 }
167
168 // Extract pubkey
169 pubkey := ExtractPubkey(event)
170
171 // Check whitelist if configured
172 if len(opts.AllowedPubkeys) > 0 {
173 if !contains(opts.AllowedPubkeys, pubkey) {
174 return "", fmt.Errorf("pubkey not in whitelist")
175 }
176 }
177
178 return pubkey, nil
179}
180
181// shouldSkipAuth checks if a method should bypass authentication.
182func shouldSkipAuth(method string, skipMethods []string) bool {
183 for _, skip := range skipMethods {
184 if skip == method {
185 return true
186 }
187 }
188 return false
189}
190
191// contains checks if a slice contains a string.
192func contains(slice []string, item string) bool {
193 for _, s := range slice {
194 if s == item {
195 return true
196 }
197 }
198 return false
199}
200
201// PubkeyFromContext retrieves the authenticated pubkey from the context.
202// Returns the pubkey and true if authentication was successful, or empty string and false otherwise.
203func PubkeyFromContext(ctx context.Context) (string, bool) {
204 pubkey, ok := ctx.Value(pubkeyContextKey).(string)
205 return pubkey, ok
206}
207
208// RequireAuth is a helper that extracts the pubkey and returns an error if not authenticated.
209func RequireAuth(ctx context.Context) (string, error) {
210 pubkey, ok := PubkeyFromContext(ctx)
211 if !ok || pubkey == "" {
212 return "", status.Error(codes.Unauthenticated, "authentication required")
213 }
214 return pubkey, nil
215}