test
0

Configure Feed

Select the types of activity you want to include in your feed.

1package auth 2 3import ( 4 "context" 5 "crypto/rand" 6 "encoding/base64" 7 "errors" 8 "fmt" 9 "log/slog" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/atcrypto" 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 16 "github.com/golang-jwt/jwt/v5" 17) 18 19// TODO: check for uniqueness of JTI (random nonce) to prevent token replay 20 21type ServiceAuthValidator struct { 22 // Service DID reference for this validator: a DID with optional #-separated fragment 23 Audience string 24 Dir identity.Directory 25 TimestampLeeway time.Duration 26} 27 28type serviceAuthClaims struct { 29 jwt.RegisteredClaims 30 31 LexMethod string `json:"lxm,omitempty"` 32} 33 34func (s *ServiceAuthValidator) Validate(ctx context.Context, tokenString string, lexMethod *syntax.NSID) (syntax.DID, error) { 35 36 leeway := s.TimestampLeeway 37 if leeway == 0 { 38 leeway = 5 * time.Second 39 } 40 41 opts := []jwt.ParserOption{ 42 jwt.WithValidMethods(supportedAlgs), 43 jwt.WithAudience(s.Audience), 44 jwt.WithExpirationRequired(), 45 jwt.WithIssuedAt(), 46 jwt.WithLeeway(leeway), 47 } 48 49 token, err := jwt.ParseWithClaims(tokenString, &serviceAuthClaims{}, s.fetchIssuerKeyFunc(ctx), opts...) 50 51 // if signature validation fails, purge the directory and try again 52 // TODO: probably need to cache or rate-limit this? 53 if err != nil && errors.Is(err, jwt.ErrTokenSignatureInvalid) { 54 55 // do an unvalidated extraction of 'iss' from JWT 56 insecure := jwt.NewParser(jwt.WithoutClaimsValidation()) 57 t, _, purgeErr := insecure.ParseUnverified(tokenString, &jwt.MapClaims{}) 58 if purgeErr != nil { 59 return "", purgeErr 60 } 61 claims, ok := t.Claims.(*jwt.MapClaims) 62 if !ok { 63 return "", jwt.ErrTokenInvalidClaims 64 } 65 iss, purgeErr := claims.GetIssuer() 66 if purgeErr != nil { 67 return "", purgeErr 68 } 69 did, purgeErr := syntax.ParseDID(iss) 70 if purgeErr != nil { 71 return "", fmt.Errorf("%w: invalid DID: %w", jwt.ErrTokenInvalidIssuer, purgeErr) 72 } 73 74 slog.Info("purging directory and retrying service auth signature validation", "did", did) 75 purgeErr = s.Dir.Purge(ctx, did.AtIdentifier()) 76 if purgeErr != nil { 77 slog.Error("purging identity directory", "did", did, "err", purgeErr) 78 } 79 token, err = jwt.ParseWithClaims(tokenString, &serviceAuthClaims{}, s.fetchIssuerKeyFunc(ctx), opts...) 80 // 'err' checked again just below 81 } 82 83 // all other errors (or second signature error) 84 if err != nil { 85 return "", err 86 } 87 88 claims, ok := token.Claims.(*serviceAuthClaims) 89 if !ok { 90 // TODO: is the error message returned descriptive enough? 91 return "", jwt.ErrTokenInvalidClaims 92 } 93 94 if lexMethod != nil && claims.LexMethod != lexMethod.String() { 95 return "", fmt.Errorf("%w: Lexicon endpoint (LXM)", jwt.ErrTokenInvalidClaims) 96 } 97 98 // NOTE: KeyFunc has already parsed issuer, so we know it is a valid DID 99 did := syntax.DID(claims.Issuer) 100 return did, nil 101} 102 103// resolves public key from identity directory 104func (s *ServiceAuthValidator) fetchIssuerKeyFunc(ctx context.Context) func(token *jwt.Token) (any, error) { 105 return func(token *jwt.Token) (any, error) { 106 claims, ok := token.Claims.(*serviceAuthClaims) 107 if !ok { 108 return nil, jwt.ErrTokenInvalidClaims 109 } 110 iss, err := claims.GetIssuer() 111 if err != nil { 112 return nil, fmt.Errorf("%w: missing 'iss' claim", jwt.ErrTokenInvalidIssuer) 113 } 114 did, err := syntax.ParseDID(iss) 115 if err != nil { 116 return nil, fmt.Errorf("%w: invalid DID: %w", jwt.ErrTokenInvalidIssuer, err) 117 } 118 // NOTE: this will do handle resolution by default 119 ident, err := s.Dir.LookupDID(ctx, did) 120 if err != nil { 121 return nil, fmt.Errorf("%w: resolving DID (%s): %w", jwt.ErrTokenInvalidIssuer, did, err) 122 } 123 return ident.PublicKey() 124 } 125} 126 127func randomNonce() string { 128 buf := make([]byte, 16) 129 rand.Read(buf) 130 return base64.RawURLEncoding.EncodeToString(buf) 131} 132 133func SignServiceAuth(iss syntax.DID, aud string, ttl time.Duration, lexMethod *syntax.NSID, priv atcrypto.PrivateKey) (string, error) { 134 claims := serviceAuthClaims{ 135 RegisteredClaims: jwt.RegisteredClaims{ 136 ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), 137 IssuedAt: jwt.NewNumericDate(time.Now()), 138 Issuer: iss.String(), 139 Audience: []string{aud}, 140 ID: randomNonce(), 141 }, 142 } 143 if lexMethod != nil { 144 claims.LexMethod = lexMethod.String() 145 } 146 147 var sm *signingMethodAtproto 148 149 // NOTE: could also have a atcrypto.PrivateKey.Alg() method which returns a string 150 switch priv.(type) { 151 case *atcrypto.PrivateKeyP256: 152 sm = signingMethodES256 153 case *atcrypto.PrivateKeyK256: 154 sm = signingMethodES256K 155 default: 156 return "", fmt.Errorf("unknown signing key type: %T", priv) 157 } 158 159 token := jwt.NewWithClaims(sm, claims) 160 return token.SignedString(priv) 161}