1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
package market
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/auth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/golang-jwt/jwt/v5"
)
const (
ServiceAuthIssuedAtLeeway = 30 * time.Second
ServiceAuthExpiryLeeway = 5 * time.Second
)
var bearerRE = regexp.MustCompile(`^Bearer (.+)$`)
func ExtractBearer(header string) (string, error) {
m := bearerRE.FindStringSubmatch(strings.TrimSpace(header))
if m == nil {
return "", fmt.Errorf("%w: missing or malformed Authorization Bearer header", ErrInvalidAuth)
}
return m[1], nil
}
func ServiceDIDForHost(hostname string) string {
return "did:web:" + hostname
}
type ServiceAuthResult struct {
IssuerDID string
Audience string
ServiceID string
}
type VerifyServiceAuthOptions struct {
AuthHeader string
Hostname string
LXM string
ServiceIDs []string
ExtraAudienceDIDs []string
}
type serviceAudience struct {
aud string
serviceID string
}
func VerifyServiceAuth(ctx context.Context, dir identity.Directory, opts VerifyServiceAuthOptions) (*ServiceAuthResult, error) {
token, err := ExtractBearer(opts.AuthHeader)
if err != nil {
return nil, err
}
if dir == nil {
return nil, fmt.Errorf("%w: no identity directory", ErrInvalidAuth)
}
if opts.Hostname == "" {
return nil, fmt.Errorf("%w: no service hostname", ErrInvalidAuth)
}
if opts.LXM == "" {
return nil, fmt.Errorf("%w: no lexicon method", ErrInvalidAuth)
}
nsid, err := syntax.ParseNSID(opts.LXM)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidAuth, err)
}
leeway, err := serviceAuthLeeway(token, time.Now())
if err != nil {
return nil, err
}
serviceDID := ServiceDIDForHost(strings.ToLower(opts.Hostname))
dids := append([]string{serviceDID, ServiceDIDForHost(opts.Hostname)}, opts.ExtraAudienceDIDs...)
if name, _, err := net.SplitHostPort(opts.Hostname); err == nil && name != "" {
dids = append(dids, ServiceDIDForHost(strings.ToLower(name)))
}
audiences := make([]serviceAudience, 0, len(dids)*(len(opts.ServiceIDs)+1))
for _, did := range dids {
audiences = append(audiences, serviceAudience{aud: did})
for _, id := range opts.ServiceIDs {
if id == "" {
continue
}
audiences = append(audiences, serviceAudience{aud: did + "#" + id, serviceID: id})
}
}
var lastErr error
for _, candidate := range audiences {
validator := &auth.ServiceAuthValidator{Audience: candidate.aud, Dir: dir, TimestampLeeway: leeway}
issuer, err := validator.Validate(ctx, token, &nsid)
if err != nil {
lastErr = err
continue
}
iss := issuer.String()
if i := strings.IndexByte(iss, '#'); i >= 0 {
iss = iss[:i]
}
if !strings.HasPrefix(iss, "did:") {
return nil, fmt.Errorf("%w: service auth token missing DID issuer", ErrInvalidAuth)
}
return &ServiceAuthResult{IssuerDID: iss, Audience: candidate.aud, ServiceID: candidate.serviceID}, nil
}
return nil, fmt.Errorf("%w: %v", ErrInvalidAuth, lastErr)
}
func AuthorizeRecord(issuerDID, recordURI string) error {
if issuerDID != ATURIAuthority(recordURI) {
return fmt.Errorf("%w: service-auth token issuer must author the referenced record", ErrForbidden)
}
return nil
}
func ServiceAudienceDIDs(host string) []string {
host = strings.TrimSpace(host)
if host == "" {
return nil
}
candidates := []string{host, strings.ToLower(host)}
if name, _, err := net.SplitHostPort(host); err == nil && name != "" {
candidates = append(candidates, name, strings.ToLower(name))
}
unique := []string{}
seen := map[string]bool{}
for _, candidate := range candidates {
if candidate == "" {
continue
}
did := ServiceDIDForHost(candidate)
if seen[did] {
continue
}
seen[did] = true
unique = append(unique, did)
}
return unique
}
func serviceAuthLeeway(token string, now time.Time) (time.Duration, error) {
claims := jwt.RegisteredClaims{}
if _, _, err := jwt.NewParser().ParseUnverified(token, &claims); err != nil {
return 0, fmt.Errorf("%w: %v", ErrInvalidAuth, err)
}
if claims.ExpiresAt == nil {
return 0, fmt.Errorf("%w: the service auth token has no exp", ErrInvalidAuth)
}
if late := now.Sub(claims.ExpiresAt.Time); late > ServiceAuthExpiryLeeway {
return 0, fmt.Errorf("%w: the service auth token expired %s ago", ErrInvalidAuth, late.Round(time.Second))
}
leeway := ServiceAuthExpiryLeeway
for _, at := range []*jwt.NumericDate{claims.IssuedAt, claims.NotBefore} {
if at == nil {
continue
}
skew := at.Sub(now)
if skew > ServiceAuthIssuedAtLeeway {
return 0, fmt.Errorf("%w: the service auth token is issued %s in this host's future (clock skew)", ErrInvalidAuth, skew.Round(time.Second))
}
if skew+time.Second > leeway {
leeway = skew + time.Second
}
}
return leeway, nil
}