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/v3"
17)
18
19var cmdAccount = &cli.Command{
20 Name: "account",
21 Usage: "commands for auth session and account management",
22 Flags: []cli.Flag{},
23 Commands: []*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 Sources: cli.EnvVars("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 Sources: cli.EnvVars("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 Sources: cli.EnvVars("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 Sources: cli.EnvVars("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 Sources: cli.EnvVars("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 Sources: cli.EnvVars("ATP_PDS_HOST"),
158 },
159 &cli.StringFlag{
160 Name: "handle",
161 Usage: "handle for new account",
162 Required: true,
163 Sources: cli.EnvVars("ATP_AUTH_HANDLE"),
164 },
165 &cli.StringFlag{
166 Name: "password",
167 Usage: "initial account password",
168 Required: true,
169 Sources: cli.EnvVars("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(ctx context.Context, cmd *cli.Command) error {
200
201 username, err := syntax.ParseAtIdentifier(cmd.String("username"))
202 if err != nil {
203 return err
204 }
205
206 _, err = refreshAuthSession(ctx, *username, cmd.String("app-password"), cmd.String("pds-host"), cmd.String("auth-factor-token"))
207 return err
208}
209
210func runAccountLogout(ctx context.Context, cmd *cli.Command) error {
211 return wipeAuthSession()
212}
213
214func runAccountLookup(ctx context.Context, cmd *cli.Command) error {
215 username := cmd.Args().First()
216 if username == "" {
217 return fmt.Errorf("need to provide username as an argument")
218 }
219 ident, err := resolveIdent(ctx, username)
220 if err != nil {
221 return err
222 }
223
224 // create a new API client to connect to the account's PDS
225 xrpcc := xrpc.Client{
226 Host: ident.PDSEndpoint(),
227 UserAgent: userAgent(),
228 }
229 if xrpcc.Host == "" {
230 return fmt.Errorf("no PDS endpoint for identity")
231 }
232
233 status, err := comatproto.SyncGetRepoStatus(ctx, &xrpcc, ident.DID.String())
234 if err != nil {
235 return err
236 }
237
238 fmt.Printf("DID: %s\n", status.Did)
239 fmt.Printf("Active: %v\n", status.Active)
240 if status.Status != nil {
241 fmt.Printf("Status: %s\n", *status.Status)
242 }
243 if status.Rev != nil {
244 fmt.Printf("Repo Rev: %s\n", *status.Rev)
245 }
246 return nil
247}
248
249func runAccountStatus(ctx context.Context, cmd *cli.Command) error {
250
251 client, err := loadAuthClient(ctx)
252 if err == ErrNoAuthSession {
253 return fmt.Errorf("auth required, but not logged in")
254 } else if err != nil {
255 return err
256 }
257
258 status, err := comatproto.ServerCheckAccountStatus(ctx, client)
259 if err != nil {
260 return fmt.Errorf("failed checking account status: %w", err)
261 }
262
263 b, err := json.MarshalIndent(status, "", " ")
264 if err != nil {
265 return err
266 }
267 fmt.Printf("DID: %s\n", client.Auth.Did)
268 fmt.Printf("Host: %s\n", client.Host)
269 fmt.Println(string(b))
270
271 return nil
272}
273
274func runAccountMissingBlobs(ctx context.Context, cmd *cli.Command) error {
275
276 client, err := loadAuthClient(ctx)
277 if err == ErrNoAuthSession {
278 return fmt.Errorf("auth required, but not logged in")
279 } else if err != nil {
280 return err
281 }
282
283 cursor := ""
284 for {
285 resp, err := comatproto.RepoListMissingBlobs(ctx, client, cursor, 500)
286 if err != nil {
287 return err
288 }
289 for _, missing := range resp.Blobs {
290 fmt.Printf("%s\t%s\n", missing.Cid, missing.RecordUri)
291 }
292 if resp.Cursor != nil && *resp.Cursor != "" {
293 cursor = *resp.Cursor
294 } else {
295 break
296 }
297 }
298 return nil
299}
300
301func runAccountActivate(ctx context.Context, cmd *cli.Command) error {
302
303 client, err := loadAuthClient(ctx)
304 if err == ErrNoAuthSession {
305 return fmt.Errorf("auth required, but not logged in")
306 } else if err != nil {
307 return err
308 }
309
310 err = comatproto.ServerActivateAccount(ctx, client)
311 if err != nil {
312 return fmt.Errorf("failed activating account: %w", err)
313 }
314
315 return nil
316}
317
318func runAccountDeactivate(ctx context.Context, cmd *cli.Command) error {
319
320 client, err := loadAuthClient(ctx)
321 if err == ErrNoAuthSession {
322 return fmt.Errorf("auth required, but not logged in")
323 } else if err != nil {
324 return err
325 }
326
327 err = comatproto.ServerDeactivateAccount(ctx, client, &comatproto.ServerDeactivateAccount_Input{})
328 if err != nil {
329 return fmt.Errorf("failed deactivating account: %w", err)
330 }
331
332 return nil
333}
334
335func runAccountUpdateHandle(ctx context.Context, cmd *cli.Command) error {
336
337 raw := cmd.Args().First()
338 if raw == "" {
339 return fmt.Errorf("need to provide new handle as argument")
340 }
341 handle, err := syntax.ParseHandle(raw)
342 if err != nil {
343 return err
344 }
345
346 client, err := loadAuthClient(ctx)
347 if err == ErrNoAuthSession {
348 return fmt.Errorf("auth required, but not logged in")
349 } else if err != nil {
350 return err
351 }
352
353 err = comatproto.IdentityUpdateHandle(ctx, client, &comatproto.IdentityUpdateHandle_Input{
354 Handle: handle.String(),
355 })
356 if err != nil {
357 return fmt.Errorf("failed updating handle: %w", err)
358 }
359
360 return nil
361}
362
363func runAccountServiceAuth(ctx context.Context, cmd *cli.Command) error {
364
365 client, err := loadAuthClient(ctx)
366 if err == ErrNoAuthSession {
367 return fmt.Errorf("auth required, but not logged in")
368 } else if err != nil {
369 return err
370 }
371
372 lxm := cmd.String("endpoint")
373 if lxm != "" {
374 _, err := syntax.ParseNSID(lxm)
375 if err != nil {
376 return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
377 }
378 }
379
380 aud := cmd.String("audience")
381 // TODO: can aud DID have a fragment?
382 _, err = syntax.ParseDID(aud)
383 if err != nil {
384 return fmt.Errorf("aud argument must be a valid DID: %w", err)
385 }
386
387 durSec := cmd.Int("duration-sec")
388 expTimestamp := time.Now().Unix() + int64(durSec)
389
390 resp, err := comatproto.ServerGetServiceAuth(ctx, client, aud, expTimestamp, lxm)
391 if err != nil {
392 return fmt.Errorf("failed updating handle: %w", err)
393 }
394
395 fmt.Println(resp.Token)
396
397 return nil
398}
399
400func runAccountServiceAuthOffline(ctx context.Context, cmd *cli.Command) error {
401 privStr := cmd.String("atproto-signing-key")
402 if privStr == "" {
403 return fmt.Errorf("private key must be provided")
404 }
405 privkey, err := crypto.ParsePrivateMultibase(privStr)
406 if err != nil {
407 return fmt.Errorf("failed parsing private key: %w", err)
408 }
409
410 issString := cmd.String("iss")
411 // TODO: support fragment identifiers
412 iss, err := syntax.ParseDID(issString)
413 if err != nil {
414 return fmt.Errorf("iss argument must be a valid DID: %w", err)
415 }
416
417 lxmString := cmd.String("endpoint")
418 var lxm *syntax.NSID = nil
419 if lxmString != "" {
420 lxmTmp, err := syntax.ParseNSID(lxmString)
421 if err != nil {
422 return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
423 }
424 lxm = &lxmTmp
425 }
426
427 aud := cmd.String("audience")
428 // TODO: can aud DID have a fragment?
429 _, err = syntax.ParseDID(aud)
430 if err != nil {
431 return fmt.Errorf("aud argument must be a valid DID: %w", err)
432 }
433
434 durSec := cmd.Int("duration-sec")
435 duration := time.Duration(durSec * int(time.Second))
436
437 token, err := auth.SignServiceAuth(iss, aud, duration, lxm, privkey)
438 if err != nil {
439 return fmt.Errorf("failed signing token: %w", err)
440 }
441
442 fmt.Println(token)
443
444 return nil
445}
446
447func runAccountCreate(ctx context.Context, cmd *cli.Command) error {
448
449 // validate args
450 pdsHost := cmd.String("pds-host")
451 if !strings.Contains(pdsHost, "://") {
452 return fmt.Errorf("PDS host is not a url: %s", pdsHost)
453 }
454 handle := cmd.String("handle")
455 _, err := syntax.ParseHandle(handle)
456 if err != nil {
457 return err
458 }
459 password := cmd.String("password")
460 params := &comatproto.ServerCreateAccount_Input{
461 Handle: handle,
462 Password: &password,
463 }
464 raw := cmd.String("existing-did")
465 if raw != "" {
466 _, err := syntax.ParseDID(raw)
467 if err != nil {
468 return err
469 }
470 s := raw
471 params.Did = &s
472 }
473 raw = cmd.String("email")
474 if raw != "" {
475 s := raw
476 params.Email = &s
477 }
478 raw = cmd.String("invite-code")
479 if raw != "" {
480 s := raw
481 params.InviteCode = &s
482 }
483 raw = cmd.String("recovery-key")
484 if raw != "" {
485 s := raw
486 params.RecoveryKey = &s
487 }
488
489 // create a new API client to connect to the account's PDS
490 xrpcc := xrpc.Client{
491 Host: pdsHost,
492 UserAgent: userAgent(),
493 }
494
495 raw = cmd.String("service-auth")
496 if raw != "" && params.Did != nil {
497 xrpcc.Auth = &xrpc.AuthInfo{
498 Did: *params.Did,
499 AccessJwt: raw,
500 }
501 }
502
503 resp, err := comatproto.ServerCreateAccount(ctx, &xrpcc, params)
504 if err != nil {
505 return fmt.Errorf("failed to create account: %w", err)
506 }
507
508 fmt.Println("Success!")
509 fmt.Printf("DID: %s\n", resp.Did)
510 fmt.Printf("Handle: %s\n", resp.Handle)
511 return nil
512}