bluesky appview implementation using microcosm and other services
server.reddwarf.app
appview
bluesky
reddwarf
microcosm
1package auth
2
3// taken from https://github.com/jazware/go-bsky-feed-generator
4// which doesnt seem to be published?
5import (
6 "context"
7 "fmt"
8 "net/http"
9 "strings"
10 "time"
11
12 "github.com/bluesky-social/indigo/atproto/identity"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 es256k "github.com/ericvolp12/jwt-go-secp256k1"
15 "github.com/gin-gonic/gin"
16 "github.com/golang-jwt/jwt"
17 lru "github.com/hashicorp/golang-lru/arc/v2"
18 "github.com/prometheus/client_golang/prometheus"
19 "github.com/prometheus/client_golang/prometheus/promauto"
20 "gitlab.com/yawning/secp256k1-voi/secec"
21 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
22 "go.opentelemetry.io/otel"
23 "go.opentelemetry.io/otel/attribute"
24 "golang.org/x/time/rate"
25)
26
27type KeyCacheEntry struct {
28 UserDID string
29 Key any
30 ExpiresAt time.Time
31}
32
33// Initialize Prometheus Metrics for cache hits and misses
34var cacheHits = promauto.NewCounterVec(prometheus.CounterOpts{
35 Name: "feedgen_auth_cache_hits_total",
36 Help: "The total number of cache hits",
37}, []string{"cache_type"})
38
39var cacheMisses = promauto.NewCounterVec(prometheus.CounterOpts{
40 Name: "feedgen_auth_cache_misses_total",
41 Help: "The total number of cache misses",
42}, []string{"cache_type"})
43
44var cacheSize = promauto.NewGaugeVec(prometheus.GaugeOpts{
45 Name: "feedgen_auth_cache_size_bytes",
46 Help: "The size of the cache in bytes",
47}, []string{"cache_type"})
48
49type Auth struct {
50 KeyCache *lru.ARCCache[string, KeyCacheEntry]
51 KeyCacheTTL time.Duration
52 ServiceDID string
53 Dir *identity.CacheDirectory
54}
55
56// NewAuth creates a new Auth instance with the given key cache size and TTL
57// The PLC Directory URL is also required, as well as the DID of the service
58// for JWT audience validation
59// The key cache is used to cache the public keys of users for a given TTL
60// The PLC Directory URL is used to fetch the public keys of users
61// The service DID is used to validate the audience of JWTs
62// The HTTP client is used to make requests to the PLC Directory
63// A rate limiter is used to limit the number of requests to the PLC Directory
64func NewAuth(
65 keyCacheSize int,
66 keyCacheTTL time.Duration,
67 requestsPerSecond int,
68 serviceDID string,
69) (*Auth, error) {
70 keyCache, err := lru.NewARC[string, KeyCacheEntry](keyCacheSize)
71 if err != nil {
72 return nil, fmt.Errorf("Failed to create key cache: %v", err)
73 }
74
75 // Initialize the HTTP client with OpenTelemetry instrumentation
76 client := http.Client{
77 Transport: otelhttp.NewTransport(http.DefaultTransport),
78 }
79
80 baseDir := identity.BaseDirectory{
81 PLCURL: identity.DefaultPLCURL,
82 PLCLimiter: rate.NewLimiter(rate.Limit(float64(requestsPerSecond)), 1),
83 HTTPClient: client,
84 TryAuthoritativeDNS: true,
85 // primary Bluesky PDS instance only supports HTTP resolution method
86 SkipDNSDomainSuffixes: []string{".bsky.social"},
87 }
88 dir := identity.NewCacheDirectory(&baseDir, keyCacheSize, keyCacheTTL, time.Minute*2, keyCacheTTL)
89
90 return &Auth{
91 KeyCache: keyCache,
92 KeyCacheTTL: keyCacheTTL,
93 ServiceDID: serviceDID,
94 Dir: &dir,
95 }, nil
96}
97
98func (auth *Auth) GetClaimsFromAuthHeader(ctx context.Context, authHeader string, claims jwt.Claims) error {
99 tracer := otel.Tracer("auth")
100 ctx, span := tracer.Start(ctx, "Auth:GetClaimsFromAuthHeader")
101 defer span.End()
102
103 if authHeader == "" {
104 span.End()
105 return fmt.Errorf("No Authorization header provided")
106 }
107
108 authHeaderParts := strings.Split(authHeader, " ")
109 if len(authHeaderParts) != 2 {
110 return fmt.Errorf("Invalid Authorization header")
111 }
112
113 if authHeaderParts[0] != "Bearer" {
114 return fmt.Errorf("Invalid Authorization header (expected Bearer)")
115 }
116
117 accessToken := authHeaderParts[1]
118
119 parser := jwt.Parser{
120 ValidMethods: []string{es256k.SigningMethodES256K.Alg()},
121 }
122
123 token, err := parser.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (interface{}, error) {
124 if claims, ok := token.Claims.(*jwt.StandardClaims); ok {
125 // Get the user's key from PLC Directory
126 userDID := claims.Issuer
127 entry, ok := auth.KeyCache.Get(userDID)
128 if ok && entry.ExpiresAt.After(time.Now()) {
129 cacheHits.WithLabelValues("key").Inc()
130 span.SetAttributes(attribute.Bool("caches.keys.hit", true))
131 return entry.Key, nil
132 }
133
134 cacheMisses.WithLabelValues("key").Inc()
135 span.SetAttributes(attribute.Bool("caches.keys.hit", false))
136
137 did, err := syntax.ParseDID(userDID)
138 if err != nil {
139 return nil, fmt.Errorf("Failed to parse user DID: %v", err)
140 }
141
142 // Get the user's key from PLC Directory
143 id, err := auth.Dir.LookupDID(ctx, did)
144 if err != nil {
145 return nil, fmt.Errorf("Failed to lookup user DID: %v", err)
146 }
147
148 key, err := id.GetPublicKey("atproto")
149 if err != nil {
150 return nil, fmt.Errorf("Failed to get user public key: %v", err)
151 }
152
153 parsedPubkey, err := secec.NewPublicKey(key.UncompressedBytes())
154 if err != nil {
155 return nil, fmt.Errorf("Failed to parse user public key: %v", err)
156 }
157
158 // Add the ECDSA key to the cache
159 auth.KeyCache.Add(userDID, KeyCacheEntry{
160 Key: parsedPubkey,
161 ExpiresAt: time.Now().Add(auth.KeyCacheTTL),
162 })
163
164 return parsedPubkey, nil
165 }
166
167 return nil, fmt.Errorf("Invalid authorization token (failed to parse claims)")
168 })
169
170 if err != nil {
171 return fmt.Errorf("Failed to parse authorization token: %v", err)
172 }
173
174 if !token.Valid {
175 return fmt.Errorf("Invalid authorization token")
176 }
177
178 return nil
179}
180
181func (auth *Auth) AuthenticateGinRequestViaJWT(c *gin.Context) {
182 tracer := otel.Tracer("auth")
183 ctx, span := tracer.Start(c.Request.Context(), "Auth:AuthenticateGinRequestViaJWT")
184
185 authHeader := c.GetHeader("Authorization")
186 if authHeader == "" {
187 span.End()
188 c.Next()
189 return
190 }
191
192 claims := jwt.StandardClaims{}
193
194 err := auth.GetClaimsFromAuthHeader(ctx, authHeader, &claims)
195 if err != nil {
196 c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Errorf("Failed to get claims from auth header: %v", err).Error()})
197 span.End()
198 c.Abort()
199 return
200 }
201
202 if claims.Audience != auth.ServiceDID {
203 c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Sprintf("Invalid audience (found '%s', expected '%s')", claims.Audience, auth.ServiceDID)})
204 c.Abort()
205 return
206 }
207
208 // Set claims Issuer to context as user DID
209 c.Set("user_did", claims.Issuer)
210 span.SetAttributes(attribute.String("user.did", claims.Issuer))
211 span.End()
212 c.Next()
213}
214
215func (auth *Auth) AuthenticateGinRequestViaJWTUnsafe(c *gin.Context) {
216 tracer := otel.Tracer("auth")
217 ctx, span := tracer.Start(c.Request.Context(), "Auth:AuthenticateGinRequestViaJWT")
218
219 authHeader := c.GetHeader("Authorization")
220 if authHeader == "" {
221 span.End()
222 c.Next()
223 return
224 }
225
226 claims := jwt.StandardClaims{}
227
228 err := auth.GetClaimsFromAuthHeader(ctx, authHeader, &claims)
229 if err != nil {
230 c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Errorf("Failed to get claims from auth header: %v", err).Error()})
231 span.End()
232 c.Abort()
233 return
234 }
235
236 // if claims.Audience != auth.ServiceDID {
237 // c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Sprintf("Invalid audience (found '%s', expected '%s')", claims.Audience, auth.ServiceDID)})
238 // c.Abort()
239 // return
240 // }
241
242 // Set claims Issuer to context as user DID
243 c.Set("user_did", claims.Issuer)
244 span.SetAttributes(attribute.String("user.did", claims.Issuer))
245 span.End()
246 c.Next()
247}