test
4.7 kB
161 lines
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}