/
client.go
199 lines (164 loc) · 6.12 KB
/
client.go
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
package store
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"github.com/snapcrafters/tokenator/internal/config"
"github.com/tidwall/gjson"
"gopkg.in/macaroon.v1"
)
// channelPermissions represents the set of ACLS applied to store tokens
// depending on which channel the token is for interacting with.
var channelPermissions map[string][]string = map[string][]string{
"candidate": {"package_access", "package_push", "package_update", "package_release"},
"stable": {"package_access", "package_release"},
}
// StoreClient is a wrapper around http.Client for logging into a Canonical store.
type StoreClient struct {
authEndpoints StoreAuthEndpoints
client *http.Client
credentials config.LoginCredentials
endpoints StoreEndpoints
}
// NewSnapStoreClient constructs a new StoreClient for interacting with the snap store.
func NewSnapStoreClient(credentials config.LoginCredentials) *StoreClient {
return &StoreClient{
endpoints: SNAP_STORE_ENDPOINTS,
authEndpoints: UBUNTU_ONE_SNAP_STORE_AUTH_ENDPOINTS,
credentials: credentials,
client: &http.Client{},
}
}
// GenerateStoreToken takes a snap, track and channel and returns a token with a
// TTL of 1 year, with default permissions for the given channel.
func (sc *StoreClient) GenerateStoreToken(snap, track, channel string) (string, error) {
permissions, ok := channelPermissions[channel]
if !ok {
return "", fmt.Errorf("invalid channel specified")
}
tokenParams := tokenParams{
Permissions: permissions,
Description: fmt.Sprintf("tokenator-%s-%s", snap, track),
TTL: 60 * 60 * 24 * 365, // 1 year
Credentials: sc.credentials,
Packages: []string{snap},
Channels: []string{fmt.Sprintf("%s/%s", track, channel)},
}
token, err := sc.login(tokenParams)
if err != nil {
return "", fmt.Errorf("failed to generate store token: %w", err)
}
return token, err
}
// login is used to login to a Canonical store and generate a scoped token
// with access to the specified packages, at the specified permissions level.
func (sc *StoreClient) login(params tokenParams) (string, error) {
tokenRequest := tokenRequest{
Permissions: params.Permissions,
Description: params.Description,
TTL: params.TTL,
Packages: []Package{},
Channels: params.Channels,
}
for _, p := range params.Packages {
tokenRequest.Packages = append(tokenRequest.Packages, NewSnapPackage(p))
}
rootMacaroon, err := sc.getRootMacaroon(tokenRequest)
if err != nil {
return "", fmt.Errorf("failed to get root macaroon: %w", err)
}
dischargedMacaroon, err := sc.getDischargedMacaroon(rootMacaroon, params)
if err != nil {
return "", fmt.Errorf("failed to get discharged macaroon: %w", err)
}
token, err := NewUbuntuOneToken(rootMacaroon, dischargedMacaroon)
if err != nil {
return "", fmt.Errorf("failed to create a valid Ubuntu One token: %w", err)
}
tokenJSON, err := json.Marshal(token)
if err != nil {
return "", fmt.Errorf("failed to marshal Ubuntu One to JSON: %w", err)
}
tokenEncoded := base64.StdEncoding.EncodeToString(tokenJSON)
return tokenEncoded, nil
}
// getDischargedMacaroon is a helper function that returns a discharged macaroon from the
// store, given a root macaroon and some credentials.
func (sc *StoreClient) getDischargedMacaroon(root *macaroon.Macaroon, params tokenParams) (*macaroon.Macaroon, error) {
u, _ := url.Parse(sc.authEndpoints.AuthURL)
idx := slices.IndexFunc(root.Caveats(), func(c macaroon.Caveat) bool {
return c.Location == u.Host
})
body := macaroonDischargeParams{
Email: params.Credentials.Login,
Password: params.Credentials.Password,
CaveatId: root.Caveats()[idx].Id,
}
resp, err := sc.post(sc.authEndpoints.AuthURL+sc.authEndpoints.TokensExchange, body)
if err != nil {
return nil, fmt.Errorf("failed to request token exchange endpoint: %w", err)
}
dischargedMacaroon, err := sc.deserializeMacaroon(resp, "discharge_macaroon")
if err != nil {
return nil, fmt.Errorf("failed to deserialize macaroon: %w", err)
}
return dischargedMacaroon, nil
}
// getRootMacaroon is a helper function that returns a root macaroon from the store.
func (sc *StoreClient) getRootMacaroon(tr tokenRequest) (*macaroon.Macaroon, error) {
resp, err := sc.post(sc.endpoints.BaseURL+sc.authEndpoints.Tokens, tr)
if err != nil {
return nil, fmt.Errorf("failed to request token exchange endpoint: %w", err)
}
rootMacaroon, err := sc.deserializeMacaroon(resp, "macaroon")
if err != nil {
return nil, fmt.Errorf("failed to deserialize macaroon: %w", err)
}
return rootMacaroon, nil
}
// deserializeMacaroon is a helper function to take any response from the store
// which contains a macaroon, and deserialize it into a macaroon.Macaroon.
func (sc *StoreClient) deserializeMacaroon(resp *http.Response, field string) (*macaroon.Macaroon, error) {
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read macaroon response body: %w", err)
}
respMac := gjson.Get(string(respBytes), field)
if !respMac.Exists() {
return nil, fmt.Errorf("no macaroon found in response json")
}
decoded, err := base64.RawURLEncoding.DecodeString(respMac.String())
if err != nil {
return nil, fmt.Errorf("failed to decode unmarshalled macaroon")
}
mac := &macaroon.Macaroon{}
err = mac.UnmarshalBinary(decoded)
if err != nil {
return nil, fmt.Errorf("failed to deserialize macaroon: %w", err)
}
return mac, nil
}
// post is a helper function for making HTTP POST requests to the store with
// the correct headers set.
func (sc *StoreClient) post(url string, body any) (*http.Response, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body to json: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to construct post request to url '%s': %w", url, err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
resp, err := sc.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request url '%s': %w", url, err)
}
return resp, err
}