@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.
Files changed (50) hide show
  1. package/README.md +41 -2
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +136 -142
  4. package/cmd/auth/auth_test.go +134 -0
  5. package/cmd/canvas/canvas.go +241 -0
  6. package/cmd/canvas/canvas_test.go +182 -0
  7. package/cmd/get_credit_balance.go +37 -0
  8. package/cmd/get_credit_balance_test.go +73 -0
  9. package/cmd/root.go +31 -5
  10. package/cmd/root_test.go +69 -0
  11. package/cmd/short_drama_test.go +5 -7
  12. package/cmd/update/update.go +34 -5
  13. package/cmd/update/update_test.go +84 -0
  14. package/dist/checksums.txt +6 -0
  15. package/dist/xyq-canvas-command-runtime.cjs +16 -0
  16. package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +599 -0
  17. package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -0
  18. package/internal/auth/auth_test.go +813 -0
  19. package/internal/auth/browser_darwin.go +14 -0
  20. package/internal/auth/browser_env.go +35 -0
  21. package/internal/auth/browser_linux.go +14 -0
  22. package/internal/auth/browser_windows.go +14 -0
  23. package/internal/auth/identity.go +89 -0
  24. package/internal/auth/loopback.go +324 -0
  25. package/internal/auth/manager.go +338 -144
  26. package/internal/auth/store.go +303 -0
  27. package/internal/auth/store_file_unix.go +176 -0
  28. package/internal/auth/store_file_windows.go +11 -0
  29. package/internal/auth/types.go +72 -0
  30. package/internal/canvas/allocate.go +70 -0
  31. package/internal/canvas/apply.go +279 -0
  32. package/internal/canvas/canvas_test.go +559 -0
  33. package/internal/canvas/create.go +380 -0
  34. package/internal/canvas/get.go +156 -0
  35. package/internal/canvas/types.go +68 -0
  36. package/internal/canvas/upload.go +250 -0
  37. package/internal/common/access_key.go +42 -6
  38. package/internal/common/access_key_test.go +113 -0
  39. package/internal/common/client.go +119 -21
  40. package/internal/common/client_test.go +213 -0
  41. package/internal/common/get_credit_balance.go +61 -0
  42. package/internal/common/get_credit_balance_test.go +75 -0
  43. package/internal/common/runner.go +15 -0
  44. package/internal/config/config.go +11 -28
  45. package/internal/config/config_test.go +3 -18
  46. package/package.json +9 -2
  47. package/scripts/canvas-command.js +881 -0
  48. package/scripts/run.js +21 -4
  49. package/skills/short-drama/SKILL.md +4 -4
  50. 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
+ }