bluesky appview implementation using microcosm and other services server.reddwarf.app
appview bluesky reddwarf microcosm
at main 7.3 kB view raw
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}