1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "time"
9
10 comatproto "github.com/bluesky-social/indigo/api/atproto"
11 "github.com/bluesky-social/indigo/atproto/auth"
12 "github.com/bluesky-social/indigo/atproto/crypto"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 "github.com/bluesky-social/indigo/xrpc"
15
16 "github.com/urfave/cli/v2"
17)
18
19var cmdAccount = &cli.Command{
20 Name: "account",
21 Usage: "sub-commands for auth and account management",
22 Flags: []cli.Flag{},
23 Subcommands: []*cli.Command{
24 &cli.Command{
25 Name: "login",
26 Usage: "create session with PDS instance",
27 Flags: []cli.Flag{
28 &cli.StringFlag{
29 Name: "username",
30 Aliases: []string{"u"},
31 Required: true,
32 Usage: "account identifier (handle or DID)",
33 EnvVars: []string{"ATP_AUTH_USERNAME"},
34 },
35 &cli.StringFlag{
36 Name: "app-password",
37 Aliases: []string{"p"},
38 Required: true,
39 Usage: "password (app password recommended)",
40 EnvVars: []string{"ATP_AUTH_PASSWORD"},
41 },
42 &cli.StringFlag{
43 Name: "auth-factor-token",
44 Usage: "token required if password is used and 2fa is required",
45 EnvVars: []string{"ATP_AUTH_FACTOR_TOKEN"},
46 },
47 &cli.StringFlag{
48 Name: "pds-host",
49 Usage: "URL of the PDS to create account on (overrides DID doc)",
50 EnvVars: []string{"ATP_PDS_HOST"},
51 },
52 },
53 Action: runAccountLogin,
54 },
55 &cli.Command{
56 Name: "logout",
57 Usage: "delete any current session",
58 Action: runAccountLogout,
59 },
60 &cli.Command{
61 Name: "activate",
62 Usage: "(re)activate current account",
63 Action: runAccountActivate,
64 },
65 &cli.Command{
66 Name: "deactivate",
67 Usage: "deactivate current account",
68 Action: runAccountDeactivate,
69 },
70 &cli.Command{
71 Name: "lookup",
72 Usage: "show basic account hosting status for any account",
73 ArgsUsage: `<at-identifier>`,
74 Action: runAccountLookup,
75 },
76 &cli.Command{
77 Name: "update-handle",
78 Usage: "change handle for current account",
79 ArgsUsage: `<handle>`,
80 Action: runAccountUpdateHandle,
81 },
82 &cli.Command{
83 Name: "status",
84 Usage: "show current account status at PDS",
85 Action: runAccountStatus,
86 },
87 &cli.Command{
88 Name: "missing-blobs",
89 Usage: "list any missing blobs for current account",
90 Action: runAccountMissingBlobs,
91 },
92 &cli.Command{
93 Name: "service-auth",
94 Usage: "ask the PDS to create a service auth token",
95 Flags: []cli.Flag{
96 &cli.StringFlag{
97 Name: "endpoint",
98 Aliases: []string{"lxm"},
99 Usage: "restrict token to API endpoint (NSID, optional)",
100 },
101 &cli.StringFlag{
102 Name: "audience",
103 Aliases: []string{"aud"},
104 Required: true,
105 Usage: "DID of service that will receive and validate token",
106 },
107 &cli.IntFlag{
108 Name: "duration-sec",
109 Value: 60,
110 Usage: "validity time window of token (seconds)",
111 },
112 },
113 Action: runAccountServiceAuth,
114 },
115 &cli.Command{
116 Name: "service-auth-offline",
117 Usage: "create service auth token via locally-held signing key",
118 Flags: []cli.Flag{
119 &cli.StringFlag{
120 Name: "atproto-signing-key",
121 Required: true,
122 Usage: "private key used to sign the token (multibase syntax)",
123 EnvVars: []string{"ATPROTO_SIGNING_KEY"},
124 },
125 &cli.StringFlag{
126 Name: "iss",
127 Required: true,
128 Usage: "the DID of the account issuing the token",
129 },
130 &cli.StringFlag{
131 Name: "endpoint",
132 Aliases: []string{"lxm"},
133 Usage: "restrict token to API endpoint (NSID, optional)",
134 },
135 &cli.StringFlag{
136 Name: "audience",
137 Aliases: []string{"aud"},
138 Required: true,
139 Usage: "DID of service that will receive and validate token",
140 },
141 &cli.IntFlag{
142 Name: "duration-sec",
143 Value: 60,
144 Usage: "validity time window of token (seconds)",
145 },
146 },
147 Action: runAccountServiceAuthOffline,
148 },
149 &cli.Command{
150 Name: "create",
151 Usage: "create a new account on the indicated PDS host",
152 Flags: []cli.Flag{
153 &cli.StringFlag{
154 Name: "pds-host",
155 Usage: "URL of the PDS to create account on",
156 Required: true,
157 EnvVars: []string{"ATP_PDS_HOST"},
158 },
159 &cli.StringFlag{
160 Name: "handle",
161 Usage: "handle for new account",
162 Required: true,
163 EnvVars: []string{"ATP_AUTH_HANDLE"},
164 },
165 &cli.StringFlag{
166 Name: "password",
167 Usage: "initial account password",
168 Required: true,
169 EnvVars: []string{"ATP_AUTH_PASSWORD"},
170 },
171 &cli.StringFlag{
172 Name: "invite-code",
173 Usage: "invite code for account signup",
174 },
175 &cli.StringFlag{
176 Name: "email",
177 Usage: "email address for new account",
178 },
179 &cli.StringFlag{
180 Name: "existing-did",
181 Usage: "an existing DID to use (eg, non-PLC DID, or migration)",
182 },
183 &cli.StringFlag{
184 Name: "recovery-key",
185 Usage: "public cryptographic key (did:key) to add as PLC recovery",
186 },
187 &cli.StringFlag{
188 Name: "service-auth",
189 Usage: "service auth token (for account migration)",
190 },
191 },
192 Action: runAccountCreate,
193 },
194 cmdAccountMigrate,
195 cmdAccountPlc,
196 },
197}
198
199func runAccountLogin(cctx *cli.Context) error {
200 ctx := context.Background()
201
202 username, err := syntax.ParseAtIdentifier(cctx.String("username"))
203 if err != nil {
204 return err
205 }
206
207 _, err = refreshAuthSession(ctx, *username, cctx.String("app-password"), cctx.String("pds-host"), cctx.String("auth-factor-token"))
208 return err
209}
210
211func runAccountLogout(cctx *cli.Context) error {
212 return wipeAuthSession()
213}
214
215func runAccountLookup(cctx *cli.Context) error {
216 ctx := context.Background()
217 username := cctx.Args().First()
218 if username == "" {
219 return fmt.Errorf("need to provide username as an argument")
220 }
221 ident, err := resolveIdent(ctx, username)
222 if err != nil {
223 return err
224 }
225
226 // create a new API client to connect to the account's PDS
227 xrpcc := xrpc.Client{
228 Host: ident.PDSEndpoint(),
229 UserAgent: userAgent(),
230 }
231 if xrpcc.Host == "" {
232 return fmt.Errorf("no PDS endpoint for identity")
233 }
234
235 status, err := comatproto.SyncGetRepoStatus(ctx, &xrpcc, ident.DID.String())
236 if err != nil {
237 return err
238 }
239
240 fmt.Printf("DID: %s\n", status.Did)
241 fmt.Printf("Active: %v\n", status.Active)
242 if status.Status != nil {
243 fmt.Printf("Status: %s\n", *status.Status)
244 }
245 if status.Rev != nil {
246 fmt.Printf("Repo Rev: %s\n", *status.Rev)
247 }
248 return nil
249}
250
251func runAccountStatus(cctx *cli.Context) error {
252 ctx := context.Background()
253
254 client, err := loadAuthClient(ctx)
255 if err == ErrNoAuthSession {
256 return fmt.Errorf("auth required, but not logged in")
257 } else if err != nil {
258 return err
259 }
260
261 status, err := comatproto.ServerCheckAccountStatus(ctx, client)
262 if err != nil {
263 return fmt.Errorf("failed checking account status: %w", err)
264 }
265
266 b, err := json.MarshalIndent(status, "", " ")
267 if err != nil {
268 return err
269 }
270 fmt.Printf("DID: %s\n", client.Auth.Did)
271 fmt.Printf("Host: %s\n", client.Host)
272 fmt.Println(string(b))
273
274 return nil
275}
276
277func runAccountMissingBlobs(cctx *cli.Context) error {
278 ctx := context.Background()
279
280 client, err := loadAuthClient(ctx)
281 if err == ErrNoAuthSession {
282 return fmt.Errorf("auth required, but not logged in")
283 } else if err != nil {
284 return err
285 }
286
287 cursor := ""
288 for {
289 resp, err := comatproto.RepoListMissingBlobs(ctx, client, cursor, 500)
290 if err != nil {
291 return err
292 }
293 for _, missing := range resp.Blobs {
294 fmt.Printf("%s\t%s\n", missing.Cid, missing.RecordUri)
295 }
296 if resp.Cursor != nil && *resp.Cursor != "" {
297 cursor = *resp.Cursor
298 } else {
299 break
300 }
301 }
302 return nil
303}
304
305func runAccountActivate(cctx *cli.Context) error {
306 ctx := context.Background()
307
308 client, err := loadAuthClient(ctx)
309 if err == ErrNoAuthSession {
310 return fmt.Errorf("auth required, but not logged in")
311 } else if err != nil {
312 return err
313 }
314
315 err = comatproto.ServerActivateAccount(ctx, client)
316 if err != nil {
317 return fmt.Errorf("failed activating account: %w", err)
318 }
319
320 return nil
321}
322
323func runAccountDeactivate(cctx *cli.Context) error {
324 ctx := context.Background()
325
326 client, err := loadAuthClient(ctx)
327 if err == ErrNoAuthSession {
328 return fmt.Errorf("auth required, but not logged in")
329 } else if err != nil {
330 return err
331 }
332
333 err = comatproto.ServerDeactivateAccount(ctx, client, &comatproto.ServerDeactivateAccount_Input{})
334 if err != nil {
335 return fmt.Errorf("failed deactivating account: %w", err)
336 }
337
338 return nil
339}
340
341func runAccountUpdateHandle(cctx *cli.Context) error {
342 ctx := context.Background()
343
344 raw := cctx.Args().First()
345 if raw == "" {
346 return fmt.Errorf("need to provide new handle as argument")
347 }
348 handle, err := syntax.ParseHandle(raw)
349 if err != nil {
350 return err
351 }
352
353 client, err := loadAuthClient(ctx)
354 if err == ErrNoAuthSession {
355 return fmt.Errorf("auth required, but not logged in")
356 } else if err != nil {
357 return err
358 }
359
360 err = comatproto.IdentityUpdateHandle(ctx, client, &comatproto.IdentityUpdateHandle_Input{
361 Handle: handle.String(),
362 })
363 if err != nil {
364 return fmt.Errorf("failed updating handle: %w", err)
365 }
366
367 return nil
368}
369
370func runAccountServiceAuth(cctx *cli.Context) error {
371 ctx := context.Background()
372
373 client, err := loadAuthClient(ctx)
374 if err == ErrNoAuthSession {
375 return fmt.Errorf("auth required, but not logged in")
376 } else if err != nil {
377 return err
378 }
379
380 lxm := cctx.String("endpoint")
381 if lxm != "" {
382 _, err := syntax.ParseNSID(lxm)
383 if err != nil {
384 return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
385 }
386 }
387
388 aud := cctx.String("audience")
389 // TODO: can aud DID have a fragment?
390 _, err = syntax.ParseDID(aud)
391 if err != nil {
392 return fmt.Errorf("aud argument must be a valid DID: %w", err)
393 }
394
395 durSec := cctx.Int("duration-sec")
396 expTimestamp := time.Now().Unix() + int64(durSec)
397
398 resp, err := comatproto.ServerGetServiceAuth(ctx, client, aud, expTimestamp, lxm)
399 if err != nil {
400 return fmt.Errorf("failed updating handle: %w", err)
401 }
402
403 fmt.Println(resp.Token)
404
405 return nil
406}
407
408func runAccountServiceAuthOffline(cctx *cli.Context) error {
409 privStr := cctx.String("atproto-signing-key")
410 if privStr == "" {
411 return fmt.Errorf("private key must be provided")
412 }
413 privkey, err := crypto.ParsePrivateMultibase(privStr)
414 if err != nil {
415 return fmt.Errorf("failed parsing private key: %w", err)
416 }
417
418 issString := cctx.String("iss")
419 // TODO: support fragment identifiers
420 iss, err := syntax.ParseDID(issString)
421 if err != nil {
422 return fmt.Errorf("iss argument must be a valid DID: %w", err)
423 }
424
425 lxmString := cctx.String("endpoint")
426 var lxm *syntax.NSID = nil
427 if lxmString != "" {
428 lxmTmp, err := syntax.ParseNSID(lxmString)
429 if err != nil {
430 return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
431 }
432 lxm = &lxmTmp
433 }
434
435 aud := cctx.String("audience")
436 // TODO: can aud DID have a fragment?
437 _, err = syntax.ParseDID(aud)
438 if err != nil {
439 return fmt.Errorf("aud argument must be a valid DID: %w", err)
440 }
441
442 durSec := cctx.Int("duration-sec")
443 duration := time.Duration(durSec * int(time.Second))
444
445 token, err := auth.SignServiceAuth(iss, aud, duration, lxm, privkey)
446 if err != nil {
447 return fmt.Errorf("failed signing token: %w", err)
448 }
449
450 fmt.Println(token)
451
452 return nil
453}
454
455func runAccountCreate(cctx *cli.Context) error {
456 ctx := context.Background()
457
458 // validate args
459 pdsHost := cctx.String("pds-host")
460 if !strings.Contains(pdsHost, "://") {
461 return fmt.Errorf("PDS host is not a url: %s", pdsHost)
462 }
463 handle := cctx.String("handle")
464 _, err := syntax.ParseHandle(handle)
465 if err != nil {
466 return err
467 }
468 password := cctx.String("password")
469 params := &comatproto.ServerCreateAccount_Input{
470 Handle: handle,
471 Password: &password,
472 }
473 raw := cctx.String("existing-did")
474 if raw != "" {
475 _, err := syntax.ParseDID(raw)
476 if err != nil {
477 return err
478 }
479 s := raw
480 params.Did = &s
481 }
482 raw = cctx.String("email")
483 if raw != "" {
484 s := raw
485 params.Email = &s
486 }
487 raw = cctx.String("invite-code")
488 if raw != "" {
489 s := raw
490 params.InviteCode = &s
491 }
492 raw = cctx.String("recovery-key")
493 if raw != "" {
494 s := raw
495 params.RecoveryKey = &s
496 }
497
498 // create a new API client to connect to the account's PDS
499 xrpcc := xrpc.Client{
500 Host: pdsHost,
501 UserAgent: userAgent(),
502 }
503
504 raw = cctx.String("service-auth")
505 if raw != "" && params.Did != nil {
506 xrpcc.Auth = &xrpc.AuthInfo{
507 Did: *params.Did,
508 AccessJwt: raw,
509 }
510 }
511
512 resp, err := comatproto.ServerCreateAccount(ctx, &xrpcc, params)
513 if err != nil {
514 return fmt.Errorf("failed to create account: %w", err)
515 }
516
517 fmt.Println("Success!")
518 fmt.Printf("DID: %s\n", resp.Did)
519 fmt.Printf("Handle: %s\n", resp.Handle)
520 return nil
521}