https://github.com/bluesky-social/goat but with tangled's CI
at main 13 kB view raw
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}