@pippit-dev/cli 1.0.17 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +136 -142
- package/cmd/auth/auth_test.go +134 -0
- package/cmd/canvas/canvas.go +241 -0
- package/cmd/canvas/canvas_test.go +182 -0
- package/cmd/get_credit_balance.go +37 -0
- package/cmd/get_credit_balance_test.go +73 -0
- package/cmd/root.go +31 -5
- package/cmd/root_test.go +69 -0
- package/cmd/short_drama_test.go +5 -7
- package/cmd/update/update.go +34 -5
- package/cmd/update/update_test.go +84 -0
- package/dist/checksums.txt +6 -0
- package/dist/xyq-canvas-command-runtime.cjs +16 -0
- package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +599 -0
- package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -0
- package/internal/auth/auth_test.go +813 -0
- package/internal/auth/browser_darwin.go +14 -0
- package/internal/auth/browser_env.go +35 -0
- package/internal/auth/browser_linux.go +14 -0
- package/internal/auth/browser_windows.go +14 -0
- package/internal/auth/identity.go +89 -0
- package/internal/auth/loopback.go +324 -0
- package/internal/auth/manager.go +338 -144
- package/internal/auth/store.go +303 -0
- package/internal/auth/store_file_unix.go +176 -0
- package/internal/auth/store_file_windows.go +11 -0
- package/internal/auth/types.go +72 -0
- package/internal/canvas/allocate.go +70 -0
- package/internal/canvas/apply.go +279 -0
- package/internal/canvas/canvas_test.go +559 -0
- package/internal/canvas/create.go +380 -0
- package/internal/canvas/get.go +156 -0
- package/internal/canvas/types.go +68 -0
- package/internal/canvas/upload.go +250 -0
- package/internal/common/access_key.go +42 -6
- package/internal/common/access_key_test.go +113 -0
- package/internal/common/client.go +119 -21
- package/internal/common/client_test.go +213 -0
- package/internal/common/get_credit_balance.go +61 -0
- package/internal/common/get_credit_balance_test.go +75 -0
- package/internal/common/runner.go +15 -0
- package/internal/config/config.go +11 -28
- package/internal/config/config_test.go +3 -18
- package/package.json +9 -2
- package/scripts/canvas-command.js +881 -0
- package/scripts/run.js +21 -4
- package/skills/short-drama/SKILL.md +4 -4
- package/skills/xyq-nest-skill/SKILL.md +11 -1
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/base64"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
"io"
|
|
11
|
+
"net/http"
|
|
12
|
+
"net/url"
|
|
13
|
+
"os"
|
|
14
|
+
"path/filepath"
|
|
15
|
+
"runtime"
|
|
16
|
+
"strings"
|
|
17
|
+
"sync"
|
|
18
|
+
"testing"
|
|
19
|
+
"time"
|
|
20
|
+
|
|
21
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
func withRandomReaderForTest(reader io.Reader) ManagerOption {
|
|
25
|
+
return func(manager *Manager) {
|
|
26
|
+
if reader != nil {
|
|
27
|
+
manager.random = reader
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func withClockForTest(now func() time.Time) ManagerOption {
|
|
33
|
+
return func(manager *Manager) {
|
|
34
|
+
if now != nil {
|
|
35
|
+
manager.now = now
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type memoryCredentialStore struct {
|
|
41
|
+
mu sync.Mutex
|
|
42
|
+
credential *Credential
|
|
43
|
+
loadErr error
|
|
44
|
+
saveErr error
|
|
45
|
+
deleteErr error
|
|
46
|
+
loads int
|
|
47
|
+
saves int
|
|
48
|
+
deletes int
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func (s *memoryCredentialStore) Load(ctx context.Context) (*Credential, error) {
|
|
52
|
+
if err := ctx.Err(); err != nil {
|
|
53
|
+
return nil, err
|
|
54
|
+
}
|
|
55
|
+
s.mu.Lock()
|
|
56
|
+
defer s.mu.Unlock()
|
|
57
|
+
s.loads++
|
|
58
|
+
if s.loadErr != nil {
|
|
59
|
+
return nil, s.loadErr
|
|
60
|
+
}
|
|
61
|
+
if s.credential == nil {
|
|
62
|
+
return nil, ErrCredentialNotFound
|
|
63
|
+
}
|
|
64
|
+
return cloneCredential(s.credential), nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func (s *memoryCredentialStore) Save(ctx context.Context, credential *Credential) error {
|
|
68
|
+
if err := ctx.Err(); err != nil {
|
|
69
|
+
return err
|
|
70
|
+
}
|
|
71
|
+
s.mu.Lock()
|
|
72
|
+
defer s.mu.Unlock()
|
|
73
|
+
s.saves++
|
|
74
|
+
if s.saveErr != nil {
|
|
75
|
+
return s.saveErr
|
|
76
|
+
}
|
|
77
|
+
s.credential = cloneCredential(credential)
|
|
78
|
+
return nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func (s *memoryCredentialStore) Delete(ctx context.Context) error {
|
|
82
|
+
if err := ctx.Err(); err != nil {
|
|
83
|
+
return err
|
|
84
|
+
}
|
|
85
|
+
s.mu.Lock()
|
|
86
|
+
defer s.mu.Unlock()
|
|
87
|
+
s.deletes++
|
|
88
|
+
if s.deleteErr != nil {
|
|
89
|
+
return s.deleteErr
|
|
90
|
+
}
|
|
91
|
+
if s.credential == nil {
|
|
92
|
+
return ErrCredentialNotFound
|
|
93
|
+
}
|
|
94
|
+
s.credential = nil
|
|
95
|
+
return nil
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func TestBrowserFlowUsesDedicatedPageAndStrictBoundCallback(t *testing.T) {
|
|
99
|
+
authURL, _ := url.Parse(config.DefaultBaseURL)
|
|
100
|
+
deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x21}, deviceIDBytes)), deviceIDBytes)
|
|
101
|
+
flow, err := startBrowserFlow(
|
|
102
|
+
authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)),
|
|
103
|
+
deviceID, "saved-token-id", accountBinding("saved-user"), true,
|
|
104
|
+
)
|
|
105
|
+
if err != nil {
|
|
106
|
+
t.Fatal(err)
|
|
107
|
+
}
|
|
108
|
+
defer flow.close()
|
|
109
|
+
|
|
110
|
+
loginURL, err := url.Parse(flow.loginURL)
|
|
111
|
+
if err != nil {
|
|
112
|
+
t.Fatal(err)
|
|
113
|
+
}
|
|
114
|
+
if loginURL.Scheme+"://"+loginURL.Host != config.DefaultBaseURL || loginURL.Path != loginPagePath {
|
|
115
|
+
t.Fatalf("login URL target = %s://%s%s", loginURL.Scheme, loginURL.Host, loginURL.Path)
|
|
116
|
+
}
|
|
117
|
+
query := loginURL.Query()
|
|
118
|
+
if query.Get("source") != loginSource || query.Get("device_id") != deviceID || query.Get("force") != "1" ||
|
|
119
|
+
query.Get("token_id") != "saved-token-id" || query.Get("expected_account") != accountBinding("saved-user") {
|
|
120
|
+
t.Fatalf("login URL public binding is incomplete: %v", query)
|
|
121
|
+
}
|
|
122
|
+
for _, field := range []string{"random_secret_key"} {
|
|
123
|
+
decoded, decodeErr := base64.RawURLEncoding.DecodeString(query.Get(field))
|
|
124
|
+
if decodeErr != nil || len(decoded) != randomBindingBytes || len(query.Get(field)) != 43 {
|
|
125
|
+
t.Fatalf("%s is not canonical 32-byte Base64URL", field)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
callbackURL := query.Get("callback")
|
|
129
|
+
parsedCallback, err := url.Parse(callbackURL)
|
|
130
|
+
if err != nil {
|
|
131
|
+
t.Fatal(err)
|
|
132
|
+
}
|
|
133
|
+
state := parsedCallback.Query().Get("state")
|
|
134
|
+
decodedState, decodeErr := base64.RawURLEncoding.DecodeString(state)
|
|
135
|
+
if parsedCallback.Scheme != "http" || parsedCallback.Hostname() != "127.0.0.1" || parsedCallback.Path != callbackPath ||
|
|
136
|
+
decodeErr != nil || len(decodedState) != randomBindingBytes || len(state) != 43 {
|
|
137
|
+
t.Fatalf("callback is not canonical: %q", callbackURL)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
payload := accessKeyPayload{
|
|
141
|
+
Type: "access_key",
|
|
142
|
+
AccessKey: "callback-ak-secret",
|
|
143
|
+
UID: "12345",
|
|
144
|
+
TokenID: "token-id",
|
|
145
|
+
ExpiredAt: time.Now().Add(time.Hour).Unix(),
|
|
146
|
+
RandomSecretKey: query.Get("random_secret_key"),
|
|
147
|
+
Source: loginSource,
|
|
148
|
+
CallbackURL: callbackURL,
|
|
149
|
+
}
|
|
150
|
+
if status := sendCallback(t, callbackURL, "https://evil.example", payload); status != http.StatusForbidden {
|
|
151
|
+
t.Fatalf("wrong origin status = %d", status)
|
|
152
|
+
}
|
|
153
|
+
wrongSecret := payload
|
|
154
|
+
wrongSecret.RandomSecretKey = "wrong-secret"
|
|
155
|
+
if status := sendCallback(t, callbackURL, config.DefaultBaseURL, wrongSecret); status != http.StatusBadRequest {
|
|
156
|
+
t.Fatalf("wrong secret status = %d", status)
|
|
157
|
+
}
|
|
158
|
+
if status := postRawCallback(t, callbackURL, config.DefaultBaseURL, `{"type":"access_key","unknown":true}`); status != http.StatusBadRequest {
|
|
159
|
+
t.Fatalf("unknown field status = %d", status)
|
|
160
|
+
}
|
|
161
|
+
if status := sendCallback(t, callbackURL+"&state=duplicate", config.DefaultBaseURL, payload); status != http.StatusBadRequest {
|
|
162
|
+
t.Fatalf("duplicate state status = %d", status)
|
|
163
|
+
}
|
|
164
|
+
if status := sendCallback(t, callbackURL+"&unexpected=value", config.DefaultBaseURL, payload); status != http.StatusBadRequest {
|
|
165
|
+
t.Fatalf("unexpected transport query status = %d", status)
|
|
166
|
+
}
|
|
167
|
+
if status := sendPreflight(t, callbackURL, config.DefaultBaseURL); status != http.StatusNoContent {
|
|
168
|
+
t.Fatalf("preflight status = %d", status)
|
|
169
|
+
}
|
|
170
|
+
securityRuntimeCallback := callbackURL + "&a_bogus=signed-request&msToken=transport-token"
|
|
171
|
+
if status := sendCallback(t, securityRuntimeCallback, config.DefaultBaseURL, payload); status != http.StatusOK {
|
|
172
|
+
t.Fatalf("security-runtime callback status = %d", status)
|
|
173
|
+
}
|
|
174
|
+
got, err := flow.wait(context.Background())
|
|
175
|
+
if err != nil || got.AccessKey != payload.AccessKey || got.UID != payload.UID || got.TokenID != payload.TokenID {
|
|
176
|
+
t.Fatalf("callback payload = %#v, %v", credentialPayloadWithoutSecret(got), err)
|
|
177
|
+
}
|
|
178
|
+
if status := sendCallback(t, callbackURL, config.DefaultBaseURL, payload); status != http.StatusConflict {
|
|
179
|
+
t.Fatalf("replayed callback status = %d", status)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func TestManagerLoginStoresPageIssuedCredentialWithoutServerExchange(t *testing.T) {
|
|
184
|
+
fixedNow := time.Unix(1_800_000_000, 0)
|
|
185
|
+
store := &memoryCredentialStore{}
|
|
186
|
+
cfg := config.Load()
|
|
187
|
+
cfg.AccessKey = ""
|
|
188
|
+
manager := NewManager(
|
|
189
|
+
cfg,
|
|
190
|
+
WithCredentialStore(store),
|
|
191
|
+
withRandomReaderForTest(bytes.NewReader(bytes.Repeat([]byte{0x31}, deviceIDBytes+2*randomBindingBytes))),
|
|
192
|
+
withClockForTest(func() time.Time { return fixedNow }),
|
|
193
|
+
)
|
|
194
|
+
var progress bytes.Buffer
|
|
195
|
+
var callbackSecret string
|
|
196
|
+
var loginURLAtOpen string
|
|
197
|
+
var loginURLShownBeforeOpen bool
|
|
198
|
+
credential, err := manager.Login(context.Background(), LoginOptions{
|
|
199
|
+
Progress: &progress,
|
|
200
|
+
OpenURL: func(rawURL string) error {
|
|
201
|
+
loginURLAtOpen = rawURL
|
|
202
|
+
loginURLShownBeforeOpen = strings.Contains(progress.String(), rawURL)
|
|
203
|
+
loginURL, parseErr := url.Parse(rawURL)
|
|
204
|
+
if parseErr != nil {
|
|
205
|
+
return parseErr
|
|
206
|
+
}
|
|
207
|
+
if loginURL.Path != loginPagePath || loginURL.Query().Get("force") != "" {
|
|
208
|
+
return fmt.Errorf("unexpected login URL")
|
|
209
|
+
}
|
|
210
|
+
callbackSecret = loginURL.Query().Get("random_secret_key")
|
|
211
|
+
payload := accessKeyPayload{
|
|
212
|
+
Type: "access_key",
|
|
213
|
+
AccessKey: "page-issued-ak",
|
|
214
|
+
UID: "user-100",
|
|
215
|
+
TokenID: "ak-id-100",
|
|
216
|
+
ExpiredAt: fixedNow.Add(time.Hour).Unix(),
|
|
217
|
+
RandomSecretKey: callbackSecret,
|
|
218
|
+
Source: loginSource,
|
|
219
|
+
CallbackURL: loginURL.Query().Get("callback"),
|
|
220
|
+
}
|
|
221
|
+
if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK {
|
|
222
|
+
return fmt.Errorf("callback status %d", status)
|
|
223
|
+
}
|
|
224
|
+
return nil
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
if err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
if credential.AccessKey != "page-issued-ak" || credential.UID != "user-100" || credential.TokenID != "ak-id-100" ||
|
|
231
|
+
credential.CredentialScope != credentialScope("user-100", credential.DeviceID) {
|
|
232
|
+
t.Fatalf("credential = %#v", credentialWithoutSecret(credential))
|
|
233
|
+
}
|
|
234
|
+
store.mu.Lock()
|
|
235
|
+
stored := cloneCredential(store.credential)
|
|
236
|
+
store.mu.Unlock()
|
|
237
|
+
if stored == nil || stored.AccessKey != credential.AccessKey {
|
|
238
|
+
t.Fatalf("stored credential = %#v", credentialWithoutSecret(stored))
|
|
239
|
+
}
|
|
240
|
+
if loginURLAtOpen == "" || !loginURLShownBeforeOpen || !strings.Contains(progress.String(), loginURLAtOpen) ||
|
|
241
|
+
!strings.Contains(progress.String(), "如未自动打开") {
|
|
242
|
+
t.Fatalf("progress omitted the manual login URL: %q", progress.String())
|
|
243
|
+
}
|
|
244
|
+
if strings.Contains(progress.String(), credential.AccessKey) {
|
|
245
|
+
t.Fatalf("progress leaked the issued Access Key: %q", progress.String())
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func TestManagerWaitsForCallbackWhenBrowserOpenFails(t *testing.T) {
|
|
250
|
+
fixedNow := time.Unix(1_800_000_000, 0)
|
|
251
|
+
store := &memoryCredentialStore{}
|
|
252
|
+
manager := NewManager(
|
|
253
|
+
config.Load(),
|
|
254
|
+
WithCredentialStore(store),
|
|
255
|
+
withRandomReaderForTest(bytes.NewReader(bytes.Repeat([]byte{0x32}, deviceIDBytes+2*randomBindingBytes))),
|
|
256
|
+
withClockForTest(func() time.Time { return fixedNow }),
|
|
257
|
+
)
|
|
258
|
+
var progress bytes.Buffer
|
|
259
|
+
credential, err := manager.Login(context.Background(), LoginOptions{
|
|
260
|
+
Progress: &progress,
|
|
261
|
+
OpenURL: func(rawURL string) error {
|
|
262
|
+
loginURL, parseErr := url.Parse(rawURL)
|
|
263
|
+
if parseErr != nil {
|
|
264
|
+
return parseErr
|
|
265
|
+
}
|
|
266
|
+
payload := accessKeyPayload{
|
|
267
|
+
Type: "access_key",
|
|
268
|
+
AccessKey: "page-issued-after-open-failure",
|
|
269
|
+
UID: "user-after-open-failure",
|
|
270
|
+
TokenID: "ak-id-after-open-failure",
|
|
271
|
+
ExpiredAt: fixedNow.Add(time.Hour).Unix(),
|
|
272
|
+
RandomSecretKey: loginURL.Query().Get("random_secret_key"),
|
|
273
|
+
Source: loginSource,
|
|
274
|
+
CallbackURL: loginURL.Query().Get("callback"),
|
|
275
|
+
}
|
|
276
|
+
if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK {
|
|
277
|
+
return fmt.Errorf("callback status %d", status)
|
|
278
|
+
}
|
|
279
|
+
return errors.New("browser opener unavailable")
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
if err != nil {
|
|
283
|
+
t.Fatalf("Login() after browser open failure error = %v", err)
|
|
284
|
+
}
|
|
285
|
+
if credential.AccessKey != "page-issued-after-open-failure" || credential.UID != "user-after-open-failure" {
|
|
286
|
+
t.Fatalf("credential = %#v", credentialWithoutSecret(credential))
|
|
287
|
+
}
|
|
288
|
+
if !strings.Contains(progress.String(), "未能自动打开浏览器") ||
|
|
289
|
+
!strings.Contains(progress.String(), "复制上方授权地址") {
|
|
290
|
+
t.Fatalf("open failure fallback was not actionable: %q", progress.String())
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) {
|
|
295
|
+
fixedNow := time.Unix(1_800_000_000, 0)
|
|
296
|
+
old, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{0x33}, deviceIDBytes)))
|
|
297
|
+
old.AccessKey = "rejected-ak"
|
|
298
|
+
old.TokenID = "rejected-id"
|
|
299
|
+
old.UID = "account-a"
|
|
300
|
+
old.ExpiredAt = fixedNow.Add(time.Hour).Unix()
|
|
301
|
+
old.CredentialScope = credentialScope(old.UID, old.DeviceID)
|
|
302
|
+
|
|
303
|
+
t.Run("different account is not saved", func(t *testing.T) {
|
|
304
|
+
store := &memoryCredentialStore{credential: cloneCredential(old)}
|
|
305
|
+
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
|
|
306
|
+
_, err := manager.Login(context.Background(), LoginOptions{
|
|
307
|
+
ForceRefresh: true,
|
|
308
|
+
ExpectedCredentialScope: old.CredentialScope,
|
|
309
|
+
OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), "account-b", "new-id", "new-ak", true),
|
|
310
|
+
})
|
|
311
|
+
if !errors.Is(err, ErrCredentialAccountMismatch) {
|
|
312
|
+
t.Fatalf("mismatch error = %v", err)
|
|
313
|
+
}
|
|
314
|
+
if store.credential.AccessKey != old.AccessKey || store.saves != 0 {
|
|
315
|
+
t.Fatal("account mismatch overwrote the stored credential")
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
t.Run("same rejected token is refused", func(t *testing.T) {
|
|
320
|
+
store := &memoryCredentialStore{credential: cloneCredential(old)}
|
|
321
|
+
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
|
|
322
|
+
_, err := manager.Login(context.Background(), LoginOptions{
|
|
323
|
+
ForceRefresh: true,
|
|
324
|
+
ExpectedCredentialScope: old.CredentialScope,
|
|
325
|
+
OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), old.UID, old.TokenID, old.AccessKey, true),
|
|
326
|
+
})
|
|
327
|
+
if err == nil || !strings.Contains(err.Error(), "未轮换") {
|
|
328
|
+
t.Fatalf("same-token force login error = %v", err)
|
|
329
|
+
}
|
|
330
|
+
if strings.Contains(err.Error(), old.AccessKey) {
|
|
331
|
+
t.Fatal("force-login error leaked the rejected Access Key")
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
t.Run("rotated token is accepted", func(t *testing.T) {
|
|
336
|
+
store := &memoryCredentialStore{credential: cloneCredential(old)}
|
|
337
|
+
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
|
|
338
|
+
got, err := manager.Login(context.Background(), LoginOptions{
|
|
339
|
+
ForceRefresh: true,
|
|
340
|
+
ExpectedCredentialScope: old.CredentialScope,
|
|
341
|
+
OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), old.UID, "rotated-id", "rotated-ak", true),
|
|
342
|
+
})
|
|
343
|
+
if err != nil || got.AccessKey != "rotated-ak" || got.CredentialScope != old.CredentialScope {
|
|
344
|
+
t.Fatalf("rotated credential = %#v, %v", credentialWithoutSecret(got), err)
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
func TestBrowserFlowRequiresExpectedAccountForForceAndNeverUsesRawUID(t *testing.T) {
|
|
350
|
+
authURL, _ := url.Parse(config.DefaultBaseURL)
|
|
351
|
+
deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x22}, deviceIDBytes)), deviceIDBytes)
|
|
352
|
+
if _, err := startBrowserFlow(
|
|
353
|
+
authURL, bytes.NewReader(bytes.Repeat([]byte{0x42}, 2*randomBindingBytes)),
|
|
354
|
+
deviceID, "saved-token-id", "", true,
|
|
355
|
+
); err == nil || !strings.Contains(err.Error(), "登录账号") {
|
|
356
|
+
t.Fatalf("force flow without account binding error = %v", err)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const rawUID = "raw-user-id-must-not-be-in-url"
|
|
360
|
+
flow, err := startBrowserFlow(
|
|
361
|
+
authURL, bytes.NewReader(bytes.Repeat([]byte{0x43}, 2*randomBindingBytes)),
|
|
362
|
+
deviceID, "saved-token-id", accountBinding(rawUID), true,
|
|
363
|
+
)
|
|
364
|
+
if err != nil {
|
|
365
|
+
t.Fatal(err)
|
|
366
|
+
}
|
|
367
|
+
defer flow.close()
|
|
368
|
+
if strings.Contains(flow.loginURL, rawUID) {
|
|
369
|
+
t.Fatal("login URL exposed raw UID")
|
|
370
|
+
}
|
|
371
|
+
parsed, err := url.Parse(flow.loginURL)
|
|
372
|
+
if err != nil {
|
|
373
|
+
t.Fatal(err)
|
|
374
|
+
}
|
|
375
|
+
if got := parsed.Query().Get("expected_account"); got != accountBinding(rawUID) || !validAccountBinding(got) {
|
|
376
|
+
t.Fatalf("expected_account = %q", got)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
func TestBrowserFlowSendsStoredTokenIDOnNormalLogin(t *testing.T) {
|
|
381
|
+
authURL, _ := url.Parse(config.DefaultBaseURL)
|
|
382
|
+
deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x23}, deviceIDBytes)), deviceIDBytes)
|
|
383
|
+
flow, err := startBrowserFlow(
|
|
384
|
+
authURL, bytes.NewReader(bytes.Repeat([]byte{0x44}, 2*randomBindingBytes)),
|
|
385
|
+
deviceID, "existing-token-id", accountBinding("existing-user"), false,
|
|
386
|
+
)
|
|
387
|
+
if err != nil {
|
|
388
|
+
t.Fatal(err)
|
|
389
|
+
}
|
|
390
|
+
defer flow.close()
|
|
391
|
+
parsed, err := url.Parse(flow.loginURL)
|
|
392
|
+
if err != nil {
|
|
393
|
+
t.Fatal(err)
|
|
394
|
+
}
|
|
395
|
+
if parsed.Query().Get("token_id") != "existing-token-id" || parsed.Query().Get("force") != "" {
|
|
396
|
+
t.Fatalf("normal login query = %v", parsed.Query())
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
func TestBrowserFlowAllowsLogoutIdentityTokenWithoutAccountBinding(t *testing.T) {
|
|
401
|
+
authURL, _ := url.Parse(config.DefaultBaseURL)
|
|
402
|
+
deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x24}, deviceIDBytes)), deviceIDBytes)
|
|
403
|
+
flow, err := startBrowserFlow(
|
|
404
|
+
authURL, bytes.NewReader(bytes.Repeat([]byte{0x45}, 2*randomBindingBytes)),
|
|
405
|
+
deviceID, "existing-token-id", "", false,
|
|
406
|
+
)
|
|
407
|
+
if err != nil {
|
|
408
|
+
t.Fatal(err)
|
|
409
|
+
}
|
|
410
|
+
defer flow.close()
|
|
411
|
+
parsed, err := url.Parse(flow.loginURL)
|
|
412
|
+
if err != nil {
|
|
413
|
+
t.Fatal(err)
|
|
414
|
+
}
|
|
415
|
+
if parsed.Query().Get("token_id") != "existing-token-id" || parsed.Query().Get("expected_account") != "" ||
|
|
416
|
+
parsed.Query().Get("force") != "" {
|
|
417
|
+
t.Fatalf("logout relogin query = %v", parsed.Query())
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
func TestManagerRejectsExpiredPageCredential(t *testing.T) {
|
|
422
|
+
fixedNow := time.Unix(1_800_000_000, 0)
|
|
423
|
+
store := &memoryCredentialStore{}
|
|
424
|
+
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
|
|
425
|
+
_, err := manager.Login(context.Background(), LoginOptions{
|
|
426
|
+
OpenURL: callbackOpener(t, fixedNow.Add(-time.Hour), "user", "id", "ak", false),
|
|
427
|
+
})
|
|
428
|
+
if !errors.Is(err, ErrCredentialExpired) {
|
|
429
|
+
t.Fatalf("expired callback error = %v", err)
|
|
430
|
+
}
|
|
431
|
+
// The fresh device identity is safe to retain, but the expired AK must not be saved.
|
|
432
|
+
if store.credential == nil || store.credential.AccessKey != "" {
|
|
433
|
+
t.Fatalf("expired callback was saved: %#v", credentialWithoutSecret(store.credential))
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
func TestManagerDerivesExpectedAccountFromDurableScope(t *testing.T) {
|
|
438
|
+
fixedNow := time.Unix(1_800_000_000, 0)
|
|
439
|
+
identity, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{0x25}, deviceIDBytes)))
|
|
440
|
+
identity.TokenID = "saved-token-id"
|
|
441
|
+
store := &memoryCredentialStore{credential: identity}
|
|
442
|
+
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
|
|
443
|
+
expectedScope := credentialScope("durable-user", identity.DeviceID)
|
|
444
|
+
_, err := manager.Login(context.Background(), LoginOptions{
|
|
445
|
+
ForceRefresh: true,
|
|
446
|
+
ExpectedCredentialScope: expectedScope,
|
|
447
|
+
OpenURL: func(rawURL string) error {
|
|
448
|
+
loginURL, parseErr := url.Parse(rawURL)
|
|
449
|
+
if parseErr != nil {
|
|
450
|
+
return parseErr
|
|
451
|
+
}
|
|
452
|
+
if got := loginURL.Query().Get("expected_account"); got != accountBinding("durable-user") ||
|
|
453
|
+
loginURL.Query().Get("force") != "1" || loginURL.Query().Get("token_id") != identity.TokenID ||
|
|
454
|
+
strings.Contains(rawURL, "durable-user") {
|
|
455
|
+
return fmt.Errorf("durable reauth binding is incomplete")
|
|
456
|
+
}
|
|
457
|
+
payload := accessKeyPayload{
|
|
458
|
+
Type: "access_key",
|
|
459
|
+
AccessKey: "new-ak",
|
|
460
|
+
UID: "durable-user",
|
|
461
|
+
TokenID: "rotated-token-id",
|
|
462
|
+
ExpiredAt: fixedNow.Add(time.Hour).Unix(),
|
|
463
|
+
RandomSecretKey: loginURL.Query().Get("random_secret_key"),
|
|
464
|
+
Source: loginSource,
|
|
465
|
+
CallbackURL: loginURL.Query().Get("callback"),
|
|
466
|
+
}
|
|
467
|
+
if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK {
|
|
468
|
+
return fmt.Errorf("callback status %d", status)
|
|
469
|
+
}
|
|
470
|
+
return nil
|
|
471
|
+
},
|
|
472
|
+
})
|
|
473
|
+
if err != nil {
|
|
474
|
+
t.Fatal(err)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
func TestResolveStatusLogoutAndAuthOrigin(t *testing.T) {
|
|
479
|
+
fixedNow := time.Unix(1_800_000_000, 0)
|
|
480
|
+
credential, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{3}, deviceIDBytes)))
|
|
481
|
+
credential.AccessKey = "stored-ak"
|
|
482
|
+
credential.TokenID = "stored-id"
|
|
483
|
+
credential.UID = "789"
|
|
484
|
+
credential.CredentialScope = credentialScope(credential.UID, credential.DeviceID)
|
|
485
|
+
credential.ExpiredAt = fixedNow.Add(time.Hour).Unix()
|
|
486
|
+
store := &memoryCredentialStore{credential: credential}
|
|
487
|
+
cfg := config.Load()
|
|
488
|
+
cfg.AccessKey = " env-ak "
|
|
489
|
+
cfg.BaseURL = "https://untrusted.invalid"
|
|
490
|
+
manager := NewManager(cfg, WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
|
|
491
|
+
if manager.authBaseURL.String() != config.DefaultBaseURL {
|
|
492
|
+
t.Fatalf("browser login followed runtime business base URL: %q", manager.authBaseURL)
|
|
493
|
+
}
|
|
494
|
+
if key, err := manager.ResolveAccessKey(context.Background()); err != nil || key != "env-ak" {
|
|
495
|
+
t.Fatalf("environment precedence = %q, %v", key, err)
|
|
496
|
+
}
|
|
497
|
+
status, err := manager.Status(context.Background())
|
|
498
|
+
if err != nil || !status.LoggedIn || status.Source != "environment" {
|
|
499
|
+
t.Fatalf("environment status = %#v, %v", status, err)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
cfg.AccessKey = ""
|
|
503
|
+
if key, err := manager.ResolveAccessKey(context.Background()); err != nil || key != credential.AccessKey {
|
|
504
|
+
t.Fatalf("stored resolution = %q, %v", key, err)
|
|
505
|
+
}
|
|
506
|
+
if scope, err := manager.CredentialScope(context.Background()); err != nil || scope != credential.CredentialScope {
|
|
507
|
+
t.Fatalf("credential scope = %q, %v", scope, err)
|
|
508
|
+
}
|
|
509
|
+
if err := manager.Logout(context.Background(), true); !errors.Is(err, ErrRemoteRevokeUnsupported) {
|
|
510
|
+
t.Fatalf("remote revoke error = %v", err)
|
|
511
|
+
}
|
|
512
|
+
if err := manager.Logout(context.Background(), false); err != nil {
|
|
513
|
+
t.Fatal(err)
|
|
514
|
+
}
|
|
515
|
+
if store.credential == nil || store.credential.DeviceID != credential.DeviceID || store.credential.AccessKey != "" ||
|
|
516
|
+
store.credential.UID != "" || store.credential.TokenID != credential.TokenID {
|
|
517
|
+
t.Fatalf("logout did not retain only device identity: %#v", credentialWithoutSecret(store.credential))
|
|
518
|
+
}
|
|
519
|
+
if _, err := manager.ResolveAccessKey(context.Background()); !errors.Is(err, ErrCredentialNotFound) {
|
|
520
|
+
t.Fatalf("post-logout resolution = %v", err)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
func TestDecodeCredentialAcceptsLegacyTokenName(t *testing.T) {
|
|
525
|
+
deviceID, _ := randomEncoded(bytes.NewReader(bytes.Repeat([]byte{0x44}, deviceIDBytes)), deviceIDBytes)
|
|
526
|
+
uid := "legacy-user"
|
|
527
|
+
payload, err := json.Marshal(map[string]any{
|
|
528
|
+
"version": credentialVersion,
|
|
529
|
+
"device_id": deviceID,
|
|
530
|
+
"credential_scope": credentialScope(uid, deviceID),
|
|
531
|
+
"token_name": "pippit-tool-cli-legacy-name",
|
|
532
|
+
"access_key": "legacy-ak",
|
|
533
|
+
"token_id": "legacy-id",
|
|
534
|
+
"uid": uid,
|
|
535
|
+
"expired_at": time.Now().Add(time.Hour).Unix(),
|
|
536
|
+
})
|
|
537
|
+
if err != nil {
|
|
538
|
+
t.Fatal(err)
|
|
539
|
+
}
|
|
540
|
+
credential, err := decodeCredential(payload)
|
|
541
|
+
if err != nil || credential.AccessKey != "legacy-ak" || credential.DeviceID != deviceID {
|
|
542
|
+
t.Fatalf("legacy credential = %#v, %v", credentialWithoutSecret(credential), err)
|
|
543
|
+
}
|
|
544
|
+
encoded, err := encodeCredential(credential)
|
|
545
|
+
if err != nil {
|
|
546
|
+
t.Fatal(err)
|
|
547
|
+
}
|
|
548
|
+
if bytes.Contains(encoded, []byte("token_name")) {
|
|
549
|
+
t.Fatalf("new credential encoding retained legacy field: %s", encoded)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
func TestManagerNormalizesLegacyDeviceScopeOnLoad(t *testing.T) {
|
|
554
|
+
credential, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{0x45}, deviceIDBytes)))
|
|
555
|
+
credential.AccessKey = "legacy-ak"
|
|
556
|
+
credential.TokenID = "legacy-id"
|
|
557
|
+
credential.UID = "legacy-user"
|
|
558
|
+
credential.ExpiredAt = time.Now().Add(time.Hour).Unix()
|
|
559
|
+
credential.CredentialScope = legacyCredentialScope(credential.DeviceID)
|
|
560
|
+
payload, err := encodeCredential(credential)
|
|
561
|
+
if err != nil {
|
|
562
|
+
t.Fatal(err)
|
|
563
|
+
}
|
|
564
|
+
decoded, err := decodeCredential(payload)
|
|
565
|
+
if err != nil {
|
|
566
|
+
t.Fatal(err)
|
|
567
|
+
}
|
|
568
|
+
store := &memoryCredentialStore{credential: decoded}
|
|
569
|
+
manager := NewManager(config.Load(), WithCredentialStore(store))
|
|
570
|
+
got, err := manager.loadCredential(context.Background())
|
|
571
|
+
if err != nil {
|
|
572
|
+
t.Fatal(err)
|
|
573
|
+
}
|
|
574
|
+
if got.CredentialScope != credentialScope(credential.UID, credential.DeviceID) {
|
|
575
|
+
t.Fatalf("legacy scope was not normalized: %q", got.CredentialScope)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
func TestManagerCredentialCacheIsConcurrent(t *testing.T) {
|
|
580
|
+
fixedNow := time.Unix(1_800_000_000, 0)
|
|
581
|
+
credential, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{3}, deviceIDBytes)))
|
|
582
|
+
credential.AccessKey = "stored-ak"
|
|
583
|
+
credential.TokenID = "stored-id"
|
|
584
|
+
credential.UID = "789"
|
|
585
|
+
credential.CredentialScope = credentialScope(credential.UID, credential.DeviceID)
|
|
586
|
+
credential.ExpiredAt = fixedNow.Add(time.Hour).Unix()
|
|
587
|
+
store := &memoryCredentialStore{credential: credential}
|
|
588
|
+
cfg := config.Load()
|
|
589
|
+
cfg.AccessKey = ""
|
|
590
|
+
manager := NewManager(cfg, WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
|
|
591
|
+
|
|
592
|
+
const callers = 24
|
|
593
|
+
start := make(chan struct{})
|
|
594
|
+
errCh := make(chan error, callers)
|
|
595
|
+
var group sync.WaitGroup
|
|
596
|
+
for index := 0; index < callers; index++ {
|
|
597
|
+
group.Add(1)
|
|
598
|
+
go func() {
|
|
599
|
+
defer group.Done()
|
|
600
|
+
<-start
|
|
601
|
+
key, err := manager.ResolveAccessKey(context.Background())
|
|
602
|
+
if err == nil && key != credential.AccessKey {
|
|
603
|
+
err = fmt.Errorf("unexpected access key")
|
|
604
|
+
}
|
|
605
|
+
errCh <- err
|
|
606
|
+
}()
|
|
607
|
+
}
|
|
608
|
+
close(start)
|
|
609
|
+
group.Wait()
|
|
610
|
+
close(errCh)
|
|
611
|
+
for err := range errCh {
|
|
612
|
+
if err != nil {
|
|
613
|
+
t.Fatal(err)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if store.loads != 1 {
|
|
617
|
+
t.Fatalf("credential store loads = %d, want 1", store.loads)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
func TestResilientStoreFallbackBoundaries(t *testing.T) {
|
|
622
|
+
identity, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{4}, deviceIDBytes)))
|
|
623
|
+
primary := &memoryCredentialStore{loadErr: ErrSecureStore, saveErr: ErrSecureStore, deleteErr: ErrSecureStore}
|
|
624
|
+
fallback := &memoryCredentialStore{}
|
|
625
|
+
store := &resilientCredentialStore{primary: primary, fallback: fallback}
|
|
626
|
+
if err := store.Save(context.Background(), identity); err != nil {
|
|
627
|
+
t.Fatal(err)
|
|
628
|
+
}
|
|
629
|
+
loaded, err := store.Load(context.Background())
|
|
630
|
+
if err != nil || loaded.DeviceID != identity.DeviceID {
|
|
631
|
+
t.Fatalf("fallback load = %#v, %v", credentialWithoutSecret(loaded), err)
|
|
632
|
+
}
|
|
633
|
+
if err := store.Delete(context.Background()); !errors.Is(err, ErrSecureStore) {
|
|
634
|
+
t.Fatalf("primary delete failure was hidden: %v", err)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
corrupt := errors.New("corrupt primary credential")
|
|
638
|
+
primary = &memoryCredentialStore{loadErr: corrupt}
|
|
639
|
+
fallback = &memoryCredentialStore{credential: identity}
|
|
640
|
+
store = &resilientCredentialStore{primary: primary, fallback: fallback}
|
|
641
|
+
if _, err := store.Load(context.Background()); !errors.Is(err, corrupt) || fallback.loads != 0 {
|
|
642
|
+
t.Fatalf("corrupt primary was masked: err=%v fallback-loads=%d", err, fallback.loads)
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
func TestFileCredentialStoreIsPrivateAtomicAndNoFollow(t *testing.T) {
|
|
647
|
+
if runtime.GOOS == "windows" {
|
|
648
|
+
t.Skip("Windows intentionally uses the system keyring without a file fallback")
|
|
649
|
+
}
|
|
650
|
+
directory := filepath.Join(t.TempDir(), "auth")
|
|
651
|
+
path := filepath.Join(directory, credentialFileName)
|
|
652
|
+
store := NewFileCredentialStore(path)
|
|
653
|
+
credential, _ := newIdentity(bytes.NewReader(bytes.Repeat([]byte{5}, deviceIDBytes)))
|
|
654
|
+
credential.AccessKey = "file-ak"
|
|
655
|
+
credential.TokenID = "file-id"
|
|
656
|
+
credential.UID = "100"
|
|
657
|
+
credential.CredentialScope = credentialScope(credential.UID, credential.DeviceID)
|
|
658
|
+
credential.ExpiredAt = time.Now().Add(time.Hour).Unix()
|
|
659
|
+
if err := store.Save(context.Background(), credential); err != nil {
|
|
660
|
+
t.Fatal(err)
|
|
661
|
+
}
|
|
662
|
+
info, err := os.Stat(path)
|
|
663
|
+
if err != nil || info.Mode().Perm() != 0o600 {
|
|
664
|
+
t.Fatalf("credential file mode = %v, %v", info, err)
|
|
665
|
+
}
|
|
666
|
+
loaded, err := store.Load(context.Background())
|
|
667
|
+
if err != nil || loaded.AccessKey != credential.AccessKey {
|
|
668
|
+
t.Fatalf("loaded credential = %#v, %v", credentialWithoutSecret(loaded), err)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if err := os.Remove(path); err != nil {
|
|
672
|
+
t.Fatal(err)
|
|
673
|
+
}
|
|
674
|
+
target := filepath.Join(t.TempDir(), "target")
|
|
675
|
+
if err := os.WriteFile(target, []byte("must-not-change"), 0o600); err != nil {
|
|
676
|
+
t.Fatal(err)
|
|
677
|
+
}
|
|
678
|
+
if err := os.Symlink(target, path); err != nil {
|
|
679
|
+
t.Fatal(err)
|
|
680
|
+
}
|
|
681
|
+
if _, err := store.Load(context.Background()); !errors.Is(err, ErrSecureStore) {
|
|
682
|
+
t.Fatalf("symlink load error = %v", err)
|
|
683
|
+
}
|
|
684
|
+
if err := store.Save(context.Background(), credential); err != nil {
|
|
685
|
+
t.Fatal(err)
|
|
686
|
+
}
|
|
687
|
+
targetData, _ := os.ReadFile(target)
|
|
688
|
+
if string(targetData) != "must-not-change" {
|
|
689
|
+
t.Fatal("atomic save followed the symlink target")
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
func TestSanitizedBrowserEnv(t *testing.T) {
|
|
694
|
+
input := []string{
|
|
695
|
+
"PATH=/usr/bin",
|
|
696
|
+
"XYQ_ACCESS_KEY=secret",
|
|
697
|
+
"PIPPIT_TOKEN=secret",
|
|
698
|
+
"PIPPIT_CLI_AK=secret",
|
|
699
|
+
"PIPPIT_CLI_MODE=desktop",
|
|
700
|
+
"OTHER_TOKEN=unrelated",
|
|
701
|
+
}
|
|
702
|
+
joined := strings.Join(SanitizedBrowserEnv(input), "\n")
|
|
703
|
+
for _, forbidden := range []string{"XYQ_ACCESS_KEY", "PIPPIT_TOKEN", "PIPPIT_CLI_AK"} {
|
|
704
|
+
if strings.Contains(joined, forbidden) {
|
|
705
|
+
t.Fatalf("browser env retained %s", forbidden)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for _, wanted := range []string{"PATH=/usr/bin", "PIPPIT_CLI_MODE=desktop", "OTHER_TOKEN=unrelated"} {
|
|
709
|
+
if !strings.Contains(joined, wanted) {
|
|
710
|
+
t.Fatalf("browser env removed %s", wanted)
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
func callbackOpener(t *testing.T, expiry time.Time, uid, tokenID, accessKey string, wantForce bool) func(string) error {
|
|
716
|
+
t.Helper()
|
|
717
|
+
return func(rawURL string) error {
|
|
718
|
+
loginURL, err := url.Parse(rawURL)
|
|
719
|
+
if err != nil {
|
|
720
|
+
return err
|
|
721
|
+
}
|
|
722
|
+
if (loginURL.Query().Get("force") == "1") != wantForce {
|
|
723
|
+
return fmt.Errorf("force query mismatch")
|
|
724
|
+
}
|
|
725
|
+
if wantForce && loginURL.Query().Get("token_id") == "" {
|
|
726
|
+
return fmt.Errorf("force login omitted token_id")
|
|
727
|
+
}
|
|
728
|
+
if wantForce && loginURL.Query().Get("expected_account") == "" {
|
|
729
|
+
return fmt.Errorf("force login omitted expected_account")
|
|
730
|
+
}
|
|
731
|
+
payload := accessKeyPayload{
|
|
732
|
+
Type: "access_key",
|
|
733
|
+
AccessKey: accessKey,
|
|
734
|
+
UID: uid,
|
|
735
|
+
TokenID: tokenID,
|
|
736
|
+
ExpiredAt: expiry.Unix(),
|
|
737
|
+
RandomSecretKey: loginURL.Query().Get("random_secret_key"),
|
|
738
|
+
Source: loginSource,
|
|
739
|
+
CallbackURL: loginURL.Query().Get("callback"),
|
|
740
|
+
}
|
|
741
|
+
if status := sendCallback(t, payload.CallbackURL, config.DefaultBaseURL, payload); status != http.StatusOK {
|
|
742
|
+
return fmt.Errorf("callback status %d", status)
|
|
743
|
+
}
|
|
744
|
+
return nil
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
func sendCallback(t *testing.T, callbackURL, origin string, payload accessKeyPayload) int {
|
|
749
|
+
t.Helper()
|
|
750
|
+
body, err := json.Marshal(payload)
|
|
751
|
+
if err != nil {
|
|
752
|
+
t.Fatal(err)
|
|
753
|
+
}
|
|
754
|
+
return postRawCallback(t, callbackURL, origin, string(body))
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
func postRawCallback(t *testing.T, callbackURL, origin, body string) int {
|
|
758
|
+
t.Helper()
|
|
759
|
+
request, err := http.NewRequest(http.MethodPost, callbackURL, strings.NewReader(body))
|
|
760
|
+
if err != nil {
|
|
761
|
+
t.Fatal(err)
|
|
762
|
+
}
|
|
763
|
+
request.Header.Set("Origin", origin)
|
|
764
|
+
request.Header.Set("Content-Type", "application/json")
|
|
765
|
+
response, err := http.DefaultClient.Do(request)
|
|
766
|
+
if err != nil {
|
|
767
|
+
t.Fatal(err)
|
|
768
|
+
}
|
|
769
|
+
defer response.Body.Close()
|
|
770
|
+
_, _ = io.Copy(io.Discard, response.Body)
|
|
771
|
+
return response.StatusCode
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
func sendPreflight(t *testing.T, callbackURL, origin string) int {
|
|
775
|
+
t.Helper()
|
|
776
|
+
request, err := http.NewRequest(http.MethodOptions, callbackURL, nil)
|
|
777
|
+
if err != nil {
|
|
778
|
+
t.Fatal(err)
|
|
779
|
+
}
|
|
780
|
+
request.Header.Set("Origin", origin)
|
|
781
|
+
request.Header.Set("Access-Control-Request-Method", "POST")
|
|
782
|
+
request.Header.Set("Access-Control-Request-Headers", "Content-Type")
|
|
783
|
+
response, err := http.DefaultClient.Do(request)
|
|
784
|
+
if err != nil {
|
|
785
|
+
t.Fatal(err)
|
|
786
|
+
}
|
|
787
|
+
defer response.Body.Close()
|
|
788
|
+
return response.StatusCode
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
func credentialWithoutSecret(credential *Credential) any {
|
|
792
|
+
if credential == nil {
|
|
793
|
+
return nil
|
|
794
|
+
}
|
|
795
|
+
return struct {
|
|
796
|
+
Version int
|
|
797
|
+
DeviceID string
|
|
798
|
+
CredentialScope string
|
|
799
|
+
TokenID string
|
|
800
|
+
UID string
|
|
801
|
+
ExpiredAt int64
|
|
802
|
+
}{credential.Version, credential.DeviceID, credential.CredentialScope, credential.TokenID, credential.UID, credential.ExpiredAt}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
func credentialPayloadWithoutSecret(payload accessKeyPayload) any {
|
|
806
|
+
return struct {
|
|
807
|
+
Type string
|
|
808
|
+
UID string
|
|
809
|
+
TokenID string
|
|
810
|
+
ExpiredAt int64
|
|
811
|
+
Source string
|
|
812
|
+
}{payload.Type, payload.UID, payload.TokenID, payload.ExpiredAt, payload.Source}
|
|
813
|
+
}
|