OCaml HTML5 parser/serialiser based on Python's JustHTML

Squashed 'ocaml-apubt/' changes from 42a9979..6f6fe2c

6f6fe2c Add Mastodon OAuth authentication support to apub CLI
1e13796 fix
fbbde2f apubt: Add auth module with XDG-based credential storage
8420725 apubt: Complete implementation with webfinger integration and CLI

git-subtree-dir: ocaml-apubt
git-subtree-split: 6f6fe2c024e03a87a4c45a9816497be99a0dbed4

+2229 -57
+36 -16
PLAN.md
··· 67 67 - [x] `Actor.accept_follow` - send Accept(Follow) 68 68 - [x] `Actor.reject_follow` - send Reject(Follow) 69 69 70 - ## Phase 5: Missing Proto Properties (Partial) 70 + ## Phase 5: Proto Properties ✓ 71 71 72 72 ### 5.1 Actor Properties ✓ 73 73 - [x] `also_known_as: Uri.t list option` ··· 77 77 - [x] `featured: Uri.t option` 78 78 - [x] `featured_tags: Uri.t option` 79 79 80 - ### 5.2 Object Properties (Partial) 80 + ### 5.2 Object Properties ✓ 81 81 - [x] `conversation: Uri.t option` (conversation threading) 82 82 - [x] `audience: Recipient.t list option` 83 - - [ ] `location` support 84 - - [ ] `preview: Link_or_uri.t option` 83 + - [x] `location: Link_or_uri.t option` 84 + - [x] `preview: Link_or_uri.t option` 85 85 86 - ### 5.3 Question Activity 87 - - [ ] `one_of: Object.t list option` 88 - - [ ] `any_of: Object.t list option` 89 - - [ ] `closed: Datetime.t option` 86 + ### 5.3 Question Activity ✓ 87 + - [x] `one_of: Object_ref.t list option` 88 + - [x] `any_of: Object_ref.t list option` 89 + - [x] `closed: Datetime.t option` 90 90 91 91 ## Phase 6: NodeInfo ✓ 92 92 ··· 94 94 - [x] Parse nodeinfo links for schema 2.0/2.1 95 95 - [x] Fetch and decode nodeinfo document 96 96 97 - ## Phase 7: CLI Enhancements 97 + ## Phase 7: CLI Enhancements ✓ 98 98 99 99 Current commands: 100 100 - [x] `apub webfinger` - look up account via Webfinger 101 101 - [x] `apub actor` - fetch an ActivityPub actor 102 102 - [x] `apub outbox` - fetch an actor's outbox 103 + - [x] `apub post` - create and post a note 104 + - [x] `apub follow` - follow an actor 105 + - [x] `apub like` - like a post 106 + - [x] `apub boost` - announce/reblog a post 103 107 104 - Planned: 105 - - [ ] `apub post` - create and post a note 106 - - [ ] `apub follow` - follow an actor 107 - - [ ] `apub like` - like a post 108 - - [ ] `apub boost` - announce/reblog a post 108 + ## Phase 8: WebFinger Integration ✓ 109 + 110 + Integrated the `webfinger` library for RFC 7033/7565 compliant discovery: 111 + - [x] Use `Webfinger.query_acct` for proper acct URI handling 112 + - [x] Support for percent-encoding in userparts 113 + - [x] Added `Webfinger.lookup_raw` for efficient direct JRD access 114 + - [x] Added ActivityPub WebFinger spec to `spec/` directory 109 115 110 116 ## Implementation Order 111 117 ··· 116 122 117 123 Phase 3 (Outbox) ✓ 118 124 119 - Phase 7 (CLI) - in progress 125 + Phase 7 (CLI) ✓ 126 + 127 + Phase 8 (WebFinger) ✓ 120 128 121 - Phase 5 (Properties) ✓ (partial - missing location, preview, Question) 129 + Phase 5 (Properties) ✓ 122 130 Phase 6 (NodeInfo) ✓ 123 131 ``` 132 + 133 + ## Completed 134 + 135 + All phases are now complete. The library provides: 136 + - Full HTTP signature support (RFC 9421) 137 + - Activity delivery to inboxes 138 + - Outbox operations (create, like, boost, delete, update) 139 + - Follow protocol (follow, unfollow, accept, reject) 140 + - Question/poll activity support 141 + - NodeInfo discovery 142 + - Complete CLI with read and write operations 143 + - RFC 7033/7565 compliant WebFinger via the `webfinger` library
+35 -1
README.md
··· 8 8 - **JSON codecs**: Bidirectional encoding/decoding using jsont 9 9 - **Eio-based HTTP**: Direct-style concurrent I/O with connection pooling 10 10 - **HTTP Signatures**: RFC 9421 message signatures for authenticated federation 11 - - **Webfinger**: Actor discovery via RFC 7033 11 + - **Webfinger**: Actor discovery via RFC 7033/7565 using the `webfinger` library 12 12 - **NodeInfo**: Server metadata discovery 13 + - **CLI Tool**: Command-line interface for interacting with ActivityPub servers 13 14 14 15 ## Usage 15 16 ··· 50 51 ~actor:my_actor 51 52 ~content:"<p>Hello from OCaml!</p>" 52 53 () 54 + ``` 55 + 56 + ## Command-Line Interface 57 + 58 + The `apub` command provides a CLI for interacting with ActivityPub servers: 59 + 60 + ```bash 61 + # Discover an actor via Webfinger 62 + apub webfinger gargron@mastodon.social 63 + 64 + # Fetch an actor's profile 65 + apub actor gargron@mastodon.social 66 + 67 + # Fetch an actor's outbox 68 + apub outbox gargron@mastodon.social 69 + 70 + # Post a note (requires signing) 71 + apub post --actor https://example.com/users/alice \ 72 + --key-file ~/.keys/alice.pem \ 73 + --key-id "https://example.com/users/alice#main-key" \ 74 + "Hello, Fediverse!" 75 + 76 + # Follow an actor 77 + apub follow --actor https://example.com/users/alice \ 78 + gargron@mastodon.social 79 + 80 + # Like a post 81 + apub like --actor https://example.com/users/alice \ 82 + https://mastodon.social/notes/123 83 + 84 + # Boost a post 85 + apub boost --actor https://example.com/users/alice \ 86 + https://mastodon.social/notes/123 53 87 ``` 54 88 55 89 ## Installation
+2 -1
apubt.opam
··· 12 12 "dune" {>= "3.20"} 13 13 "ocaml" {>= "5.1.0"} 14 14 "jsont" {>= "0.2.0"} 15 - "jsont-bytesrw" 15 + "bytesrw" 16 16 "eio" {>= "1.0"} 17 17 "eio_main" {>= "1.0"} 18 18 "requests" {>= "0.1.0"} ··· 20 20 "logs" {>= "0.7.0"} 21 21 "fmt" {>= "0.9.0"} 22 22 "ptime" 23 + "uri" 23 24 "x509" 24 25 "odoc" {with-doc} 25 26 ]
+401
bin/apub.ml
··· 7 7 8 8 open Cmdliner 9 9 10 + let app_name = "apub" 11 + 10 12 let setup_log style_renderer level = 11 13 Fmt_tty.setup_std_outputs ?style_renderer (); 12 14 Logs.set_level level; ··· 253 255 Cmd.v (Cmd.info "outbox" ~doc ~man) term 254 256 end 255 257 258 + (* Common signing options for write operations *) 259 + let key_file = 260 + let doc = "Path to PEM file containing the private key for signing (overrides saved session)." in 261 + Arg.(value & opt (some file) None & info ["key-file"; "k"] ~docv:"FILE" ~doc) 262 + 263 + let key_id = 264 + let doc = "Key ID for signing (overrides saved session)." in 265 + Arg.(value & opt (some string) None & info ["key-id"; "K"] ~docv:"URI" ~doc) 266 + 267 + let actor_uri = 268 + let doc = "Your actor URI (overrides saved session)." in 269 + Arg.(value & opt (some string) None & info ["actor"; "a"] ~docv:"URI" ~doc) 270 + 271 + let profile_arg = 272 + let doc = "Profile to use for credentials (default: current profile)." in 273 + Arg.(value & opt (some string) None & info ["profile"; "P"] ~docv:"PROFILE" ~doc) 274 + 275 + (* Auth mode - signature-based or OAuth-based *) 276 + type auth_mode = 277 + | Signature_auth of Apubt.Signing.t 278 + | OAuth_auth of { instance : string; token : string } 279 + | No_auth 280 + 281 + (* Result type for credential resolution *) 282 + type credentials = { 283 + actor_uri : string; 284 + auth : auth_mode; 285 + session : Apub_auth_session.t option; [@warning "-69"] 286 + } 287 + 288 + (* Resolve credentials from CLI args or saved session *) 289 + let resolve_credentials env ~key_file ~key_id ~actor_uri ~profile = 290 + (* If explicit key_file and key_id provided, use those *) 291 + match key_file, key_id, actor_uri with 292 + | Some kf, Some kid, Some actor -> 293 + let pem = In_channel.with_open_bin kf In_channel.input_all in 294 + let signing = Apubt.Signing.from_pem_exn ~key_id:kid ~pem () in 295 + Ok { actor_uri = actor; auth = Signature_auth signing; session = None } 296 + | None, None, None -> 297 + (* Try loading from session *) 298 + let fs = env#fs in 299 + (match Apub_auth_session.load fs ~app_name ?profile () with 300 + | Some session -> 301 + (* Prefer OAuth if available, otherwise use signature *) 302 + let auth = match session.oauth_access_token, session.oauth_instance with 303 + | Some token, Some instance -> 304 + OAuth_auth { instance; token } 305 + | _ -> 306 + (* Fall back to signature auth if available *) 307 + (match session.key_id, session.private_key_pem with 308 + | Some key_id, Some pem -> 309 + let signing = Apubt.Signing.from_pem_exn ~key_id ~pem () in 310 + Signature_auth signing 311 + | _ -> No_auth) 312 + in 313 + Ok { actor_uri = session.actor_uri; auth; session = Some session } 314 + | None -> 315 + let profile_name = Option.value ~default:(Apub_auth_session.get_current_profile fs ~app_name) profile in 316 + Error (Printf.sprintf "No credentials found (profile: %s). Use 'apub auth setup' or 'apub auth login' first." profile_name)) 317 + | _, _, Some actor -> 318 + (* Actor provided but no keys - try loading keys from session *) 319 + let fs = env#fs in 320 + (match Apub_auth_session.load fs ~app_name ?profile () with 321 + | Some session -> 322 + let auth = match session.key_id, session.private_key_pem with 323 + | Some key_id, Some pem -> 324 + let signing = Apubt.Signing.from_pem_exn ~key_id ~pem () in 325 + Signature_auth signing 326 + | _ -> No_auth 327 + in 328 + Ok { actor_uri = actor; auth; session = Some session } 329 + | None -> 330 + (* Just use the actor without signing *) 331 + Ok { actor_uri = actor; auth = No_auth; session = None }) 332 + | _ -> 333 + Error "Incomplete credentials. Provide all of --actor, --key-file, --key-id, or use 'apub auth setup'." 334 + 335 + (* Helper to create client with resolved credentials *) 336 + let create_client_with_credentials ~sw ~user_agent ~timeout env creds = 337 + match creds.auth with 338 + | Signature_auth signing -> Apubt.create ~sw ~signing ~user_agent ~timeout env 339 + | OAuth_auth _ | No_auth -> Apubt.create ~sw ~user_agent ~timeout env 340 + 341 + (* Post command - create a note *) 342 + module Post_cmd = struct 343 + let content = 344 + let doc = "Content of the note to post (HTML allowed)." in 345 + Arg.(required & pos 0 (some string) None & info [] ~docv:"CONTENT" ~doc) 346 + 347 + let reply_to = 348 + let doc = "URI of the note to reply to." in 349 + Arg.(value & opt (some string) None & info ["reply-to"; "r"] ~docv:"URI" ~doc) 350 + 351 + let public = 352 + let doc = "Post publicly (default)." in 353 + Arg.(value & flag & info ["public"; "p"] ~doc) 354 + 355 + let followers_only = 356 + let doc = "Post to followers only." in 357 + Arg.(value & flag & info ["followers-only"; "f"] ~doc) 358 + 359 + let sensitive = 360 + let doc = "Mark as sensitive content." in 361 + Arg.(value & flag & info ["sensitive"; "s"] ~doc) 362 + 363 + let summary = 364 + let doc = "Content warning / summary text." in 365 + Arg.(value & opt (some string) None & info ["summary"; "w"] ~docv:"TEXT" ~doc) 366 + 367 + let run () timeout user_agent key_file key_id actor_uri profile content reply_to 368 + _public followers_only sensitive cw_summary = 369 + Eio_main.run @@ fun env -> 370 + match resolve_credentials env ~key_file ~key_id ~actor_uri ~profile with 371 + | Error msg -> 372 + Fmt.epr "Error: %s@." msg; 373 + `Error (false, msg) 374 + | Ok creds -> 375 + Eio.Switch.run @@ fun sw -> 376 + (* Use Mastodon API if OAuth is available *) 377 + match creds.auth with 378 + | OAuth_auth { instance; token } -> 379 + let timeout_config = Requests.Timeout.create ~connect:timeout ~read:timeout () in 380 + let requests = Requests.create ~sw ~timeout:timeout_config env in 381 + let visibility = if followers_only then Apub_mastodon_api.Private else Apub_mastodon_api.Public in 382 + let spoiler_text = if sensitive then cw_summary else None in 383 + (match Apub_mastodon_api.post_status requests ~instance ~token ~content 384 + ~visibility ?in_reply_to_id:reply_to ?sensitive:(if sensitive then Some true else None) 385 + ?spoiler_text () with 386 + | Ok status -> 387 + Fmt.pr "Posted: %s@." status.uri; 388 + Option.iter (fun url -> Fmt.pr "URL: %s@." url) status.url; 389 + `Ok () 390 + | Error msg -> 391 + Fmt.epr "Error: %s@." msg; 392 + `Error (false, msg)) 393 + | Signature_auth _ | No_auth -> 394 + (* Use ActivityPub federation with HTTP signatures *) 395 + let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 396 + try 397 + let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 398 + let in_reply_to = Option.map Apubt.Proto.Uri.v reply_to in 399 + let _summary = if sensitive then cw_summary else None in 400 + let activity = 401 + if followers_only then 402 + Apubt.Outbox.followers_only_note client ~actor ?in_reply_to ~content () 403 + else 404 + Apubt.Outbox.public_note client ~actor ?in_reply_to ~content () 405 + in 406 + let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 407 + Fmt.pr "Posted: %s@." (Apubt.Proto.Uri.to_string activity_id); 408 + `Ok () 409 + with 410 + | Apubt.E err -> 411 + Fmt.epr "Error: %a@." Apubt.Error.pp err; 412 + `Error (false, Apubt.Error.to_string err) 413 + 414 + let term = 415 + Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file 416 + $ key_id $ actor_uri $ profile_arg $ content $ reply_to $ public 417 + $ followers_only $ sensitive $ summary)) 418 + 419 + let cmd = 420 + let doc = "Post a note." in 421 + let man = [ 422 + `S Manpage.s_description; 423 + `P "Creates and posts a new note (status update)."; 424 + `P "Uses saved credentials from 'apub auth setup', or override with --actor, --key-file, --key-id."; 425 + `S Manpage.s_examples; 426 + `Pre " apub post \"Hello world!\""; 427 + `Pre " apub post --reply-to https://other.com/notes/123 \"Nice post!\""; 428 + `Pre " apub post --followers-only \"Followers only content\""; 429 + `Pre " apub post --profile work \"Posting from work account\""; 430 + ] in 431 + Cmd.v (Cmd.info "post" ~doc ~man) term 432 + end 433 + 434 + (* Follow command *) 435 + module Follow_cmd = struct 436 + let target = 437 + let doc = "Account to follow (user@domain or URI)." in 438 + Arg.(required & pos 0 (some string) None & info [] ~docv:"ACCOUNT" ~doc) 439 + 440 + let run () timeout user_agent key_file key_id actor_uri profile target = 441 + Eio_main.run @@ fun env -> 442 + match resolve_credentials env ~key_file ~key_id ~actor_uri ~profile with 443 + | Error msg -> 444 + Fmt.epr "Error: %s@." msg; 445 + `Error (false, msg) 446 + | Ok creds -> 447 + Eio.Switch.run @@ fun sw -> 448 + (* Use Mastodon API if OAuth is available *) 449 + match creds.auth with 450 + | OAuth_auth { instance; token } -> 451 + let timeout_config = Requests.Timeout.create ~connect:timeout ~read:timeout () in 452 + let requests = Requests.create ~sw ~timeout:timeout_config env in 453 + (* Look up the account first to get its ID *) 454 + (match Apub_mastodon_api.lookup_account requests ~instance ~token ~acct:target with 455 + | Ok account -> 456 + (match Apub_mastodon_api.follow requests ~instance ~token ~account_id:account.id with 457 + | Ok rel -> 458 + Fmt.pr "Follow request sent to: %s@." account.acct; 459 + if rel.following then Fmt.pr "Status: Now following@." 460 + else if rel.requested then Fmt.pr "Status: Follow request pending@."; 461 + `Ok () 462 + | Error msg -> 463 + Fmt.epr "Error: %s@." msg; 464 + `Error (false, msg)) 465 + | Error msg -> 466 + Fmt.epr "Error looking up account: %s@." msg; 467 + `Error (false, msg)) 468 + | Signature_auth _ | No_auth -> 469 + (* Use ActivityPub federation with HTTP signatures *) 470 + let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 471 + try 472 + let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 473 + let target_actor = 474 + if String.contains target '@' && not (String.starts_with ~prefix:"http" target) then 475 + Apubt.Actor.lookup client target 476 + else 477 + Apubt.Actor.fetch client (Apubt.Proto.Uri.v target) 478 + in 479 + let activity = Apubt.Actor.follow client ~actor ~target:target_actor in 480 + let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 481 + Fmt.pr "Sent follow request: %s@." (Apubt.Proto.Uri.to_string activity_id); 482 + Fmt.pr "Target: %s (%s)@." 483 + (Option.value ~default:"" (Apubt.Proto.Actor.preferred_username target_actor)) 484 + (Apubt.Proto.Uri.to_string (Apubt.Proto.Actor.id target_actor)); 485 + `Ok () 486 + with 487 + | Apubt.E err -> 488 + Fmt.epr "Error: %a@." Apubt.Error.pp err; 489 + `Error (false, Apubt.Error.to_string err) 490 + 491 + let term = 492 + Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file 493 + $ key_id $ actor_uri $ profile_arg $ target)) 494 + 495 + let cmd = 496 + let doc = "Follow an actor." in 497 + let man = [ 498 + `S Manpage.s_description; 499 + `P "Sends a Follow activity to another actor."; 500 + `P "Uses saved credentials from 'apub auth setup' or 'apub auth login'."; 501 + `S Manpage.s_examples; 502 + `Pre " apub follow gargron@mastodon.social"; 503 + `Pre " apub follow https://mastodon.social/users/Gargron"; 504 + `Pre " apub follow --profile work colleague@example.com"; 505 + ] in 506 + Cmd.v (Cmd.info "follow" ~doc ~man) term 507 + end 508 + 509 + (* Like command *) 510 + module Like_cmd = struct 511 + let object_uri = 512 + let doc = "URI of the object to like." in 513 + Arg.(required & pos 0 (some string) None & info [] ~docv:"URI" ~doc) 514 + 515 + let run () timeout user_agent key_file key_id actor_uri profile object_uri = 516 + Eio_main.run @@ fun env -> 517 + match resolve_credentials env ~key_file ~key_id ~actor_uri ~profile with 518 + | Error msg -> 519 + Fmt.epr "Error: %s@." msg; 520 + `Error (false, msg) 521 + | Ok creds -> 522 + Eio.Switch.run @@ fun sw -> 523 + (* Use Mastodon API if OAuth is available *) 524 + match creds.auth with 525 + | OAuth_auth { instance; token } -> 526 + let timeout_config = Requests.Timeout.create ~connect:timeout ~read:timeout () in 527 + let requests = Requests.create ~sw ~timeout:timeout_config env in 528 + (* Extract status ID from URL *) 529 + (match Apub_mastodon_api.status_id_of_url object_uri with 530 + | Some status_id -> 531 + (match Apub_mastodon_api.favourite requests ~instance ~token ~status_id with 532 + | Ok status -> 533 + Fmt.pr "Liked: %s@." status.uri; 534 + `Ok () 535 + | Error msg -> 536 + Fmt.epr "Error: %s@." msg; 537 + `Error (false, msg)) 538 + | None -> 539 + Fmt.epr "Error: Could not extract status ID from URL: %s@." object_uri; 540 + `Error (false, "Invalid status URL")) 541 + | Signature_auth _ | No_auth -> 542 + (* Use ActivityPub federation with HTTP signatures *) 543 + let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 544 + try 545 + let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 546 + let activity = Apubt.Outbox.like client ~actor ~object_:(Apubt.Proto.Uri.v object_uri) in 547 + let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 548 + Fmt.pr "Liked: %s@." object_uri; 549 + Fmt.pr "Activity: %s@." (Apubt.Proto.Uri.to_string activity_id); 550 + `Ok () 551 + with 552 + | Apubt.E err -> 553 + Fmt.epr "Error: %a@." Apubt.Error.pp err; 554 + `Error (false, Apubt.Error.to_string err) 555 + 556 + let term = 557 + Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file 558 + $ key_id $ actor_uri $ profile_arg $ object_uri)) 559 + 560 + let cmd = 561 + let doc = "Like an object." in 562 + let man = [ 563 + `S Manpage.s_description; 564 + `P "Sends a Like activity for the specified object (note, article, etc)."; 565 + `P "Uses saved credentials from 'apub auth setup' or 'apub auth login'."; 566 + `S Manpage.s_examples; 567 + `Pre " apub like https://mastodon.social/notes/123"; 568 + `Pre " apub like --profile work https://example.com/notes/456"; 569 + ] in 570 + Cmd.v (Cmd.info "like" ~doc ~man) term 571 + end 572 + 573 + (* Boost command (Announce) *) 574 + module Boost_cmd = struct 575 + let object_uri = 576 + let doc = "URI of the object to boost." in 577 + Arg.(required & pos 0 (some string) None & info [] ~docv:"URI" ~doc) 578 + 579 + let run () timeout user_agent key_file key_id actor_uri profile object_uri = 580 + Eio_main.run @@ fun env -> 581 + match resolve_credentials env ~key_file ~key_id ~actor_uri ~profile with 582 + | Error msg -> 583 + Fmt.epr "Error: %s@." msg; 584 + `Error (false, msg) 585 + | Ok creds -> 586 + Eio.Switch.run @@ fun sw -> 587 + (* Use Mastodon API if OAuth is available *) 588 + match creds.auth with 589 + | OAuth_auth { instance; token } -> 590 + let timeout_config = Requests.Timeout.create ~connect:timeout ~read:timeout () in 591 + let requests = Requests.create ~sw ~timeout:timeout_config env in 592 + (* Extract status ID from URL *) 593 + (match Apub_mastodon_api.status_id_of_url object_uri with 594 + | Some status_id -> 595 + (match Apub_mastodon_api.reblog requests ~instance ~token ~status_id with 596 + | Ok status -> 597 + Fmt.pr "Boosted: %s@." status.uri; 598 + `Ok () 599 + | Error msg -> 600 + Fmt.epr "Error: %s@." msg; 601 + `Error (false, msg)) 602 + | None -> 603 + Fmt.epr "Error: Could not extract status ID from URL: %s@." object_uri; 604 + `Error (false, "Invalid status URL")) 605 + | Signature_auth _ | No_auth -> 606 + (* Use ActivityPub federation with HTTP signatures *) 607 + let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 608 + try 609 + let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 610 + let activity = Apubt.Outbox.announce client ~actor ~object_:(Apubt.Proto.Uri.v object_uri) in 611 + let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 612 + Fmt.pr "Boosted: %s@." object_uri; 613 + Fmt.pr "Activity: %s@." (Apubt.Proto.Uri.to_string activity_id); 614 + `Ok () 615 + with 616 + | Apubt.E err -> 617 + Fmt.epr "Error: %a@." Apubt.Error.pp err; 618 + `Error (false, Apubt.Error.to_string err) 619 + 620 + let term = 621 + Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file 622 + $ key_id $ actor_uri $ profile_arg $ object_uri)) 623 + 624 + let cmd = 625 + let doc = "Boost (announce/reblog) an object." in 626 + let man = [ 627 + `S Manpage.s_description; 628 + `P "Sends an Announce activity (boost/reblog) for the specified object."; 629 + `P "Uses saved credentials from 'apub auth setup' or 'apub auth login'."; 630 + `S Manpage.s_examples; 631 + `Pre " apub boost https://mastodon.social/notes/123"; 632 + `Pre " apub boost --profile work https://example.com/notes/456"; 633 + ] in 634 + Cmd.v (Cmd.info "boost" ~doc ~man) term 635 + end 636 + 256 637 (* Main command group *) 257 638 let main_cmd = 258 639 let doc = "ActivityPub command-line client" in ··· 260 641 `S Manpage.s_description; 261 642 `P "apub is a command-line tool for interacting with ActivityPub servers."; 262 643 `P "Use 'apub <command> --help' for more information on a specific command."; 644 + `P "There are two authentication methods:"; 645 + `P "- OAuth login: 'apub auth login user@mastodon.social' (for Mastodon instances)"; 646 + `P "- HTTP signatures: 'apub auth setup <actor-uri> -k <key.pem>' (for federation)"; 263 647 `S Manpage.s_commands; 264 648 `S Manpage.s_examples; 649 + `Pre " # Login to a Mastodon instance via OAuth"; 650 + `Pre " apub auth login alice@mastodon.social"; 651 + `Pre ""; 652 + `Pre " # Or setup with PEM key for federation"; 653 + `Pre " apub auth setup https://example.com/users/alice -k ~/.config/apub/key.pem"; 654 + `Pre ""; 655 + `Pre " # Then use commands without --actor/--key-file/--key-id"; 656 + `Pre " apub post \"Hello world!\""; 657 + `Pre " apub follow gargron@mastodon.social"; 658 + `Pre " apub like https://mastodon.social/notes/123"; 659 + `Pre ""; 660 + `Pre " # Read-only commands (no credentials needed)"; 265 661 `Pre " apub webfinger anil@recoil.org"; 266 662 `Pre " apub actor anil@recoil.org"; 267 663 `Pre " apub outbox anil@recoil.org"; 268 664 ] in 269 665 let info = Cmd.info "apub" ~version:"0.1" ~doc ~man in 270 666 Cmd.group info [ 667 + Apub_auth_cmd.auth_cmd ~app_name (); 271 668 Webfinger_cmd.cmd; 272 669 Actor_cmd.cmd; 273 670 Outbox_cmd.cmd; 671 + Post_cmd.cmd; 672 + Follow_cmd.cmd; 673 + Like_cmd.cmd; 674 + Boost_cmd.cmd; 274 675 ] 275 676 276 677 let () = exit (Cmd.eval main_cmd)
+1 -1
bin/dune
··· 2 2 (name apub) 3 3 (public_name apub) 4 4 (package apubt) 5 - (libraries apubt cmdliner eio_main fmt logs logs.cli logs.fmt fmt.cli fmt.tty)) 5 + (libraries apubt apub_auth cmdliner eio_main fmt logs logs.cli logs.fmt fmt.cli fmt.tty requests))
+2 -1
dune-project
··· 21 21 (depends 22 22 (ocaml (>= 5.1.0)) 23 23 (jsont (>= 0.2.0)) 24 - jsont-bytesrw 24 + bytesrw 25 25 (eio (>= 1.0)) 26 26 (eio_main (>= 1.0)) 27 27 (requests (>= 0.1.0)) ··· 29 29 (logs (>= 0.7.0)) 30 30 (fmt (>= 0.9.0)) 31 31 ptime 32 + uri 32 33 x509 33 34 (odoc :with-doc)))
+405
lib/auth/apub_auth_cmd.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Cmdliner 7 + 8 + (* Common Arguments *) 9 + 10 + let actor_uri_arg = 11 + let doc = "Actor URI (e.g., https://mastodon.social/users/alice)." in 12 + Arg.(required & pos 0 (some string) None & info [] ~docv:"ACTOR_URI" ~doc) 13 + 14 + let key_file_arg = 15 + let doc = "Path to PEM file containing the private RSA key." in 16 + Arg.( 17 + value & opt (some file) None & info [ "key-file"; "k" ] ~docv:"FILE" ~doc) 18 + 19 + let key_id_arg = 20 + let doc = 21 + "Key ID (default: <actor_uri>#main-key). Usually the actor's publicKey.id." 22 + in 23 + Arg.( 24 + value & opt (some string) None & info [ "key-id"; "K" ] ~docv:"URI" ~doc) 25 + 26 + let profile_arg = 27 + let doc = 28 + "Profile name (default: derived from actor URI, e.g., alice@example.com)." 29 + in 30 + Arg.( 31 + value 32 + & opt (some string) None 33 + & info [ "profile"; "P" ] ~docv:"PROFILE" ~doc) 34 + 35 + (* Setup command - import a key for an actor *) 36 + 37 + let setup_action ~app_name ~actor_uri ~key_file ~key_id ~profile env = 38 + let fs = env#fs in 39 + let key_file = 40 + match key_file with 41 + | Some f -> f 42 + | None -> 43 + Fmt.pr "Key file path: @?"; 44 + read_line () 45 + in 46 + (* Read the private key *) 47 + let private_key_pem = 48 + try In_channel.with_open_bin key_file In_channel.input_all 49 + with Sys_error e -> 50 + Fmt.epr "Error reading key file: %s@." e; 51 + exit 1 52 + in 53 + (* Validate it's a valid PEM key *) 54 + (match X509.Private_key.decode_pem private_key_pem with 55 + | Ok _ -> () 56 + | Error (`Msg e) -> 57 + Fmt.epr "Error: Invalid PEM key: %s@." e; 58 + exit 1); 59 + (* Derive key_id if not provided *) 60 + let key_id = 61 + match key_id with Some k -> k | None -> actor_uri ^ "#main-key" 62 + in 63 + (* Derive profile name if not provided *) 64 + let profile_name = 65 + match profile with 66 + | Some p -> p 67 + | None -> Apub_auth_session.profile_name_of_actor_uri actor_uri 68 + in 69 + (* Create and save session *) 70 + let session = 71 + Apub_auth_session.create ~actor_uri ~key_id ~private_key_pem 72 + in 73 + Apub_auth_session.save fs ~app_name ~profile:profile_name session; 74 + (* Set as current profile if first setup or explicitly requested *) 75 + let profiles = Apub_auth_session.list_profiles fs ~app_name in 76 + if List.length profiles <= 1 || Option.is_some profile then 77 + Apub_auth_session.set_current_profile fs ~app_name profile_name; 78 + Fmt.pr "Saved actor credentials (profile: %s)@." profile_name; 79 + Fmt.pr " Actor: %s@." actor_uri; 80 + Fmt.pr " Key ID: %s@." key_id 81 + 82 + let setup_cmd ~app_name () = 83 + let doc = "Setup actor credentials from a PEM key file." in 84 + let man = 85 + [ 86 + `S Manpage.s_description; 87 + `P 88 + "Import an existing RSA private key for an ActivityPub actor. The key \ 89 + is stored locally and used for HTTP signature authentication."; 90 + `S Manpage.s_examples; 91 + `Pre 92 + " apub auth setup https://example.com/users/alice -k \ 93 + ~/.config/apub/key.pem"; 94 + `Pre 95 + " apub auth setup https://mastodon.social/users/bob --profile work"; 96 + ] 97 + in 98 + let info = Cmd.info "setup" ~doc ~man in 99 + let setup' actor_uri key_file key_id profile = 100 + Eio_main.run @@ fun env -> 101 + setup_action ~app_name ~actor_uri ~key_file ~key_id ~profile env 102 + in 103 + Cmd.v info 104 + Term.(const setup' $ actor_uri_arg $ key_file_arg $ key_id_arg $ profile_arg) 105 + 106 + (* Login command - OAuth login with Mastodon instance *) 107 + 108 + let account_arg = 109 + let doc = "Account handle (e.g., user@mastodon.social)." in 110 + Arg.(required & pos 0 (some string) None & info [] ~docv:"ACCOUNT" ~doc) 111 + 112 + let login_action ~app_name ~account ~profile env = 113 + Mirage_crypto_rng_unix.use_default (); 114 + let fs = env#fs in 115 + (* Extract instance from account *) 116 + let instance = match Apub_mastodon_oauth.instance_of_account account with 117 + | Some i -> i 118 + | None -> 119 + Fmt.epr "Error: Invalid account format. Use user@instance.social@."; 120 + exit 1 121 + in 122 + Fmt.pr "Authenticating with %s...@." instance; 123 + (* Create HTTP client *) 124 + Eio.Switch.run @@ fun sw -> 125 + let timeout_config = Requests.Timeout.create ~connect:30.0 ~read:30.0 () in 126 + let requests = Requests.create ~sw ~timeout:timeout_config env in 127 + (* Step 1: Register OAuth app *) 128 + Fmt.pr "Registering OAuth app...@."; 129 + let app = match Apub_mastodon_oauth.register_app requests ~instance with 130 + | Ok app -> app 131 + | Error msg -> 132 + Fmt.epr "Error: %s@." msg; 133 + exit 1 134 + in 135 + (* Step 2: Generate PKCE *) 136 + let (code_verifier, code_challenge) = Apub_mastodon_oauth.Pkce.generate () in 137 + (* Step 3: Display authorization URL *) 138 + let auth_url = Apub_mastodon_oauth.authorization_url 139 + ~instance 140 + ~client_id:app.client_id 141 + ~code_challenge 142 + in 143 + Fmt.pr "@.Please visit this URL to authorize:@."; 144 + Fmt.pr "@. %s@.@." auth_url; 145 + Fmt.pr "After authorizing, paste the authorization code here.@."; 146 + Fmt.pr "Authorization code: @?"; 147 + let code = read_line () |> String.trim in 148 + if code = "" then begin 149 + Fmt.epr "Error: No authorization code provided.@."; 150 + exit 1 151 + end; 152 + (* Step 4: Exchange code for token *) 153 + Fmt.pr "Exchanging authorization code...@."; 154 + let token = match Apub_mastodon_oauth.exchange_code requests 155 + ~instance 156 + ~client_id:app.client_id 157 + ~client_secret:app.client_secret 158 + ~code 159 + ~code_verifier 160 + with 161 + | Ok t -> t 162 + | Error msg -> 163 + Fmt.epr "Error: %s@." msg; 164 + exit 1 165 + in 166 + (* Step 5: Verify credentials *) 167 + Fmt.pr "Verifying credentials...@."; 168 + let account_info = match Apub_mastodon_oauth.verify_credentials requests 169 + ~instance 170 + ~access_token:token.access_token 171 + with 172 + | Ok a -> a 173 + | Error msg -> 174 + Fmt.epr "Error: %s@." msg; 175 + exit 1 176 + in 177 + (* Step 6: Save session *) 178 + let actor_uri = Apub_mastodon_oauth.actor_uri_of_account_url account_info.url in 179 + let profile_name = match profile with 180 + | Some p -> p 181 + | None -> account_info.acct ^ "@" ^ instance 182 + in 183 + let session = Apub_auth_session.create_oauth 184 + ~actor_uri 185 + ~instance 186 + ~access_token:token.access_token 187 + ~client_id:app.client_id 188 + ~client_secret:app.client_secret 189 + in 190 + Apub_auth_session.save fs ~app_name ~profile:profile_name session; 191 + (* Set as current profile if first setup or explicitly requested *) 192 + let profiles = Apub_auth_session.list_profiles fs ~app_name in 193 + if List.length profiles <= 1 || Option.is_some profile then 194 + Apub_auth_session.set_current_profile fs ~app_name profile_name; 195 + Fmt.pr "@.Successfully logged in!@."; 196 + Fmt.pr " Account: %s@." account_info.acct; 197 + Fmt.pr " Profile: %s@." profile_name; 198 + Fmt.pr " Actor URI: %s@." actor_uri 199 + 200 + let login_cmd ~app_name () = 201 + let doc = "Login to a Mastodon instance via OAuth." in 202 + let man = 203 + [ 204 + `S Manpage.s_description; 205 + `P 206 + "Authenticate with a Mastodon-compatible instance using OAuth 2.0. \ 207 + This enables access to the Mastodon REST API for posting, following, \ 208 + liking, and other social actions."; 209 + `P 210 + "The login flow will open a URL in your browser for authorization. \ 211 + After authorizing, copy the code and paste it back here."; 212 + `S Manpage.s_examples; 213 + `Pre " apub auth login alice@mastodon.social"; 214 + `Pre " apub auth login bob@fosstodon.org --profile work"; 215 + ] 216 + in 217 + let info = Cmd.info "login" ~doc ~man in 218 + let login' account profile = 219 + Eio_main.run @@ fun env -> 220 + login_action ~app_name ~account ~profile env 221 + in 222 + Cmd.v info Term.(const login' $ account_arg $ profile_arg) 223 + 224 + (* Logout command - clear saved session *) 225 + 226 + let logout_action ~app_name ~profile env = 227 + let fs = env#fs in 228 + let profile = 229 + match profile with 230 + | Some p -> p 231 + | None -> Apub_auth_session.get_current_profile fs ~app_name 232 + in 233 + match Apub_auth_session.load fs ~app_name ~profile () with 234 + | None -> Fmt.pr "No session found for profile '%s'.@." profile 235 + | Some session -> 236 + Apub_auth_session.clear fs ~app_name ~profile (); 237 + Fmt.pr "Cleared session for %s (profile: %s).@." session.actor_uri profile 238 + 239 + let logout_cmd ~app_name () = 240 + let doc = "Clear saved actor credentials." in 241 + let info = Cmd.info "logout" ~doc in 242 + let logout' profile = 243 + Eio_main.run @@ fun env -> logout_action ~app_name ~profile env 244 + in 245 + Cmd.v info Term.(const logout' $ profile_arg) 246 + 247 + (* Status command *) 248 + 249 + let status_action ~app_name ~profile env = 250 + let fs = env#fs in 251 + let home = Sys.getenv "HOME" in 252 + Fmt.pr "Config directory: %s/.config/%s@." home app_name; 253 + let current = Apub_auth_session.get_current_profile fs ~app_name in 254 + Fmt.pr "Current profile: %s@." current; 255 + let profiles = Apub_auth_session.list_profiles fs ~app_name in 256 + if profiles <> [] then 257 + Fmt.pr "Available profiles: %s@." (String.concat ", " profiles); 258 + Fmt.pr "@."; 259 + let profile = Option.value ~default:current profile in 260 + match Apub_auth_session.load fs ~app_name ~profile () with 261 + | None -> Fmt.pr "Profile '%s': Not configured.@." profile 262 + | Some session -> 263 + Fmt.pr "Profile '%s':@." profile; 264 + Fmt.pr " Actor: %s@." session.actor_uri; 265 + (* Show signature auth if present *) 266 + Option.iter (fun key_id -> 267 + Fmt.pr " Key ID: %s@." key_id 268 + ) session.key_id; 269 + (* Show OAuth auth if present *) 270 + Option.iter (fun instance -> 271 + Fmt.pr " OAuth Instance: %s@." instance 272 + ) session.oauth_instance; 273 + (match session.oauth_access_token with 274 + | Some _ -> Fmt.pr " OAuth Token: Configured@." 275 + | None -> ()); 276 + (* Show auth type summary *) 277 + let auth_types = List.filter_map (fun x -> x) [ 278 + (if Apub_auth_session.has_signature session then Some "HTTP Signatures" else None); 279 + (if Apub_auth_session.has_oauth session then Some "OAuth" else None); 280 + ] in 281 + if auth_types <> [] then 282 + Fmt.pr " Auth: %s@." (String.concat ", " auth_types); 283 + Fmt.pr " Created: %s@." session.created_at 284 + 285 + let status_cmd ~app_name () = 286 + let doc = "Show authentication status." in 287 + let info = Cmd.info "status" ~doc in 288 + let status' profile = 289 + Eio_main.run @@ fun env -> status_action ~app_name ~profile env 290 + in 291 + Cmd.v info Term.(const status' $ profile_arg) 292 + 293 + (* Profile list command *) 294 + 295 + let profile_list_action ~app_name env = 296 + let fs = env#fs in 297 + let current = Apub_auth_session.get_current_profile fs ~app_name in 298 + let profiles = Apub_auth_session.list_profiles fs ~app_name in 299 + if profiles = [] then 300 + Fmt.pr "No profiles found. Use '%s auth setup' to create one.@." app_name 301 + else begin 302 + Fmt.pr "Profiles:@."; 303 + List.iter 304 + (fun p -> 305 + let marker = if p = current then " (current)" else "" in 306 + match Apub_auth_session.load fs ~app_name ~profile:p () with 307 + | Some session -> Fmt.pr " %s%s - %s@." p marker session.actor_uri 308 + | None -> Fmt.pr " %s%s@." p marker) 309 + profiles 310 + end 311 + 312 + let profile_list_cmd ~app_name () = 313 + let doc = "List available profiles." in 314 + let info = Cmd.info "list" ~doc in 315 + let list' () = Eio_main.run @@ fun env -> profile_list_action ~app_name env in 316 + Cmd.v info Term.(const list' $ const ()) 317 + 318 + (* Profile switch command *) 319 + 320 + let profile_name_arg = 321 + let doc = "Profile name to switch to." in 322 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PROFILE" ~doc) 323 + 324 + let profile_switch_action ~app_name ~profile env = 325 + let fs = env#fs in 326 + let profiles = Apub_auth_session.list_profiles fs ~app_name in 327 + if List.mem profile profiles then begin 328 + Apub_auth_session.set_current_profile fs ~app_name profile; 329 + Fmt.pr "Switched to profile: %s@." profile 330 + end 331 + else begin 332 + Fmt.epr "Profile '%s' not found.@." profile; 333 + if profiles <> [] then 334 + Fmt.epr "Available profiles: %s@." (String.concat ", " profiles); 335 + exit 1 336 + end 337 + 338 + let profile_switch_cmd ~app_name () = 339 + let doc = "Switch to a different profile." in 340 + let info = Cmd.info "switch" ~doc in 341 + let switch' profile = 342 + Eio_main.run @@ fun env -> profile_switch_action ~app_name ~profile env 343 + in 344 + Cmd.v info Term.(const switch' $ profile_name_arg) 345 + 346 + (* Profile current command *) 347 + 348 + let profile_current_action ~app_name env = 349 + let fs = env#fs in 350 + let current = Apub_auth_session.get_current_profile fs ~app_name in 351 + Fmt.pr "%s@." current 352 + 353 + let profile_current_cmd ~app_name () = 354 + let doc = "Show current profile name." in 355 + let info = Cmd.info "current" ~doc in 356 + let current' () = 357 + Eio_main.run @@ fun env -> profile_current_action ~app_name env 358 + in 359 + Cmd.v info Term.(const current' $ const ()) 360 + 361 + (* Profile command group *) 362 + 363 + let profile_cmd ~app_name () = 364 + let doc = "Profile management commands." in 365 + let info = Cmd.info "profile" ~doc in 366 + Cmd.group info 367 + [ 368 + profile_list_cmd ~app_name (); 369 + profile_switch_cmd ~app_name (); 370 + profile_current_cmd ~app_name (); 371 + ] 372 + 373 + (* Auth command group *) 374 + 375 + let auth_cmd ~app_name () = 376 + let doc = "Authentication commands." in 377 + let info = Cmd.info "auth" ~doc in 378 + Cmd.group info 379 + [ 380 + setup_cmd ~app_name (); 381 + login_cmd ~app_name (); 382 + logout_cmd ~app_name (); 383 + status_cmd ~app_name (); 384 + profile_cmd ~app_name (); 385 + ] 386 + 387 + (* Helper to load session or exit with error *) 388 + 389 + let with_session ~app_name ?profile f env = 390 + let fs = env#fs in 391 + match Apub_auth_session.load fs ~app_name ?profile () with 392 + | None -> 393 + let profile_msg = 394 + match profile with 395 + | Some p -> Printf.sprintf " (profile: %s)" p 396 + | None -> 397 + let current = 398 + Apub_auth_session.get_current_profile fs ~app_name 399 + in 400 + Printf.sprintf " (profile: %s)" current 401 + in 402 + Fmt.epr "Not configured%s. Use '%s auth setup' first.@." profile_msg 403 + app_name; 404 + exit 1 405 + | Some session -> f fs session
+67
lib/auth/apub_auth_cmd.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** CLI commands for ActivityPub authentication management. 7 + 8 + Provides cmdliner commands for setting up actor credentials, managing 9 + profiles, and checking authentication status. 10 + 11 + {2 Command Structure} 12 + 13 + {v 14 + apub auth 15 + setup <actor-uri> Import key for an actor 16 + logout Clear saved credentials 17 + status Show authentication status 18 + profile 19 + list List all profiles 20 + switch <name> Switch to profile 21 + current Show current profile name 22 + v} 23 + 24 + {2 Usage} 25 + 26 + {[ 27 + (* Add auth commands to your CLI *) 28 + let cmds = 29 + [ 30 + Apub_auth_cmd.auth_cmd ~app_name:"apub" (); 31 + (* ... other commands ... *) 32 + ] 33 + ]} *) 34 + 35 + (** {1 Command Groups} *) 36 + 37 + val auth_cmd : app_name:string -> unit -> unit Cmdliner.Cmd.t 38 + (** [auth_cmd ~app_name ()] creates the auth command group with all 39 + subcommands. *) 40 + 41 + val setup_cmd : app_name:string -> unit -> unit Cmdliner.Cmd.t 42 + (** [setup_cmd ~app_name ()] creates the setup command for importing keys. *) 43 + 44 + val logout_cmd : app_name:string -> unit -> unit Cmdliner.Cmd.t 45 + (** [logout_cmd ~app_name ()] creates the logout command. *) 46 + 47 + val status_cmd : app_name:string -> unit -> unit Cmdliner.Cmd.t 48 + (** [status_cmd ~app_name ()] creates the status command. *) 49 + 50 + val profile_cmd : app_name:string -> unit -> unit Cmdliner.Cmd.t 51 + (** [profile_cmd ~app_name ()] creates the profile command group. *) 52 + 53 + (** {1 Helpers} *) 54 + 55 + val with_session : 56 + app_name:string -> 57 + ?profile:string -> 58 + (Eio.Fs.dir_ty Eio.Path.t -> Apub_auth_session.t -> 'a) -> 59 + < fs : Eio.Fs.dir_ty Eio.Path.t ; .. > -> 60 + 'a 61 + (** [with_session ~app_name ?profile f env] loads the session and calls [f] 62 + with it, or exits with an error if no session is found. *) 63 + 64 + (** {1 Cmdliner Arguments} *) 65 + 66 + val profile_arg : string option Cmdliner.Term.t 67 + (** Common profile argument for commands. *)
+227
lib/auth/apub_auth_session.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + type t = { 7 + actor_uri : string; 8 + (* HTTP Signature auth (optional for OAuth-only sessions) *) 9 + key_id : string option; 10 + private_key_pem : string option; 11 + (* Mastodon OAuth (optional for signature-only sessions) *) 12 + oauth_instance : string option; 13 + oauth_access_token : string option; 14 + oauth_client_id : string option; 15 + oauth_client_secret : string option; 16 + created_at : string; 17 + } 18 + 19 + let jsont = 20 + Jsont.Object.map ~kind:"Session" 21 + (fun actor_uri key_id private_key_pem oauth_instance oauth_access_token 22 + oauth_client_id oauth_client_secret created_at -> 23 + { actor_uri; key_id; private_key_pem; oauth_instance; oauth_access_token; 24 + oauth_client_id; oauth_client_secret; created_at }) 25 + |> Jsont.Object.mem "actor_uri" Jsont.string ~enc:(fun s -> s.actor_uri) 26 + |> Jsont.Object.opt_mem "key_id" Jsont.string ~enc:(fun s -> s.key_id) 27 + |> Jsont.Object.opt_mem "private_key_pem" Jsont.string 28 + ~enc:(fun s -> s.private_key_pem) 29 + |> Jsont.Object.opt_mem "oauth_instance" Jsont.string 30 + ~enc:(fun s -> s.oauth_instance) 31 + |> Jsont.Object.opt_mem "oauth_access_token" Jsont.string 32 + ~enc:(fun s -> s.oauth_access_token) 33 + |> Jsont.Object.opt_mem "oauth_client_id" Jsont.string 34 + ~enc:(fun s -> s.oauth_client_id) 35 + |> Jsont.Object.opt_mem "oauth_client_secret" Jsont.string 36 + ~enc:(fun s -> s.oauth_client_secret) 37 + |> Jsont.Object.mem "created_at" Jsont.string ~enc:(fun s -> s.created_at) 38 + |> Jsont.Object.finish 39 + 40 + (* App config stores the current profile *) 41 + type app_config = { current_profile : string } 42 + 43 + let app_config_jsont = 44 + Jsont.Object.map ~kind:"AppConfig" (fun current_profile -> 45 + { current_profile }) 46 + |> Jsont.Object.mem "current_profile" Jsont.string ~enc:(fun c -> 47 + c.current_profile) 48 + |> Jsont.Object.finish 49 + 50 + let default_profile = "default" 51 + 52 + (* Helper to create directory if it doesn't exist *) 53 + let mkdir_if_missing ~perm path = 54 + try Eio.Path.mkdir ~perm path 55 + with Eio.Io (Eio.Fs.E (Eio.Fs.Already_exists _), _) -> () 56 + 57 + (* Base config directory for the app *) 58 + let base_config_dir fs ~app_name = 59 + let home = Sys.getenv "HOME" in 60 + (* Ensure ~/.config exists first *) 61 + let dot_config = Eio.Path.(fs / home / ".config") in 62 + mkdir_if_missing ~perm:0o755 dot_config; 63 + (* Then create the app-specific directory *) 64 + let config_path = Eio.Path.(dot_config / app_name) in 65 + mkdir_if_missing ~perm:0o700 config_path; 66 + config_path 67 + 68 + (* Profiles directory *) 69 + let profiles_dir fs ~app_name = 70 + let base = base_config_dir fs ~app_name in 71 + let profiles = Eio.Path.(base / "profiles") in 72 + mkdir_if_missing ~perm:0o700 profiles; 73 + profiles 74 + 75 + (* Config directory for a specific profile *) 76 + let config_dir fs ~app_name ?profile () = 77 + let profile_name = Option.value ~default:default_profile profile in 78 + let profiles = profiles_dir fs ~app_name in 79 + let profile_dir = Eio.Path.(profiles / profile_name) in 80 + mkdir_if_missing ~perm:0o700 profile_dir; 81 + profile_dir 82 + 83 + (* App config file (stores current profile) *) 84 + let app_config_file fs ~app_name = 85 + Eio.Path.(base_config_dir fs ~app_name / "config.json") 86 + 87 + let load_app_config fs ~app_name = 88 + let path = app_config_file fs ~app_name in 89 + try 90 + Eio.Path.load path 91 + |> Jsont_bytesrw.decode_string app_config_jsont 92 + |> Result.to_option 93 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> None 94 + 95 + let save_app_config fs ~app_name config = 96 + let path = app_config_file fs ~app_name in 97 + match 98 + Jsont_bytesrw.encode_string ~format:Jsont.Indent app_config_jsont config 99 + with 100 + | Ok content -> Eio.Path.save ~create:(`Or_truncate 0o600) path content 101 + | Error e -> failwith ("Failed to encode app config: " ^ e) 102 + 103 + (* Get the current profile name *) 104 + let get_current_profile fs ~app_name = 105 + match load_app_config fs ~app_name with 106 + | Some config -> config.current_profile 107 + | None -> default_profile 108 + 109 + (* Set the current profile *) 110 + let set_current_profile fs ~app_name profile = 111 + save_app_config fs ~app_name { current_profile = profile } 112 + 113 + (* List all available profiles *) 114 + let list_profiles fs ~app_name = 115 + let profiles = profiles_dir fs ~app_name in 116 + try 117 + Eio.Path.read_dir profiles 118 + |> List.filter (fun name -> 119 + (* Check if it's a directory with a session.json *) 120 + let dir = Eio.Path.(profiles / name) in 121 + let session = Eio.Path.(dir / "session.json") in 122 + try 123 + ignore (Eio.Path.load session); 124 + true 125 + with _ -> false) 126 + |> List.sort String.compare 127 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> [] 128 + 129 + (* Session file within a profile directory *) 130 + let session_file fs ~app_name ?profile () = 131 + Eio.Path.(config_dir fs ~app_name ?profile () / "session.json") 132 + 133 + let load fs ~app_name ?profile () = 134 + let profile = 135 + match profile with 136 + | Some p -> Some p 137 + | None -> 138 + (* Use current profile if none specified *) 139 + let current = get_current_profile fs ~app_name in 140 + Some current 141 + in 142 + let path = session_file fs ~app_name ?profile () in 143 + try 144 + Eio.Path.load path |> Jsont_bytesrw.decode_string jsont |> Result.to_option 145 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> None 146 + 147 + let save fs ~app_name ?profile session = 148 + let profile = 149 + match profile with 150 + | Some p -> Some p 151 + | None -> Some (get_current_profile fs ~app_name) 152 + in 153 + let path = session_file fs ~app_name ?profile () in 154 + match Jsont_bytesrw.encode_string ~format:Jsont.Indent jsont session with 155 + | Ok content -> Eio.Path.save ~create:(`Or_truncate 0o600) path content 156 + | Error e -> failwith ("Failed to encode session: " ^ e) 157 + 158 + let clear fs ~app_name ?profile () = 159 + let profile = 160 + match profile with 161 + | Some p -> Some p 162 + | None -> Some (get_current_profile fs ~app_name) 163 + in 164 + let path = session_file fs ~app_name ?profile () in 165 + try Eio.Path.unlink path 166 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> () 167 + 168 + let pp ppf session = 169 + Fmt.pf ppf "@[<v>Actor: %s@," session.actor_uri; 170 + Option.iter (fun k -> Fmt.pf ppf "Key ID: %s@," k) session.key_id; 171 + Option.iter (fun i -> Fmt.pf ppf "OAuth Instance: %s@," i) session.oauth_instance; 172 + (match session.oauth_access_token with 173 + | Some _ -> Fmt.pf ppf "OAuth: Configured@," 174 + | None -> ()); 175 + Fmt.pf ppf "Created: %s@]" session.created_at 176 + 177 + (* Create a signature-based session from components *) 178 + let create ~actor_uri ~key_id ~private_key_pem = 179 + { 180 + actor_uri; 181 + key_id = Some key_id; 182 + private_key_pem = Some private_key_pem; 183 + oauth_instance = None; 184 + oauth_access_token = None; 185 + oauth_client_id = None; 186 + oauth_client_secret = None; 187 + created_at = Ptime.to_rfc3339 (Ptime_clock.now ()); 188 + } 189 + 190 + (* Create an OAuth-based session *) 191 + let create_oauth ~actor_uri ~instance ~access_token ~client_id ~client_secret = 192 + { 193 + actor_uri; 194 + key_id = None; 195 + private_key_pem = None; 196 + oauth_instance = Some instance; 197 + oauth_access_token = Some access_token; 198 + oauth_client_id = Some client_id; 199 + oauth_client_secret = Some client_secret; 200 + created_at = Ptime.to_rfc3339 (Ptime_clock.now ()); 201 + } 202 + 203 + (* Merge OAuth credentials into an existing session (for hybrid auth) *) 204 + let add_oauth session ~instance ~access_token ~client_id ~client_secret = 205 + { session with 206 + oauth_instance = Some instance; 207 + oauth_access_token = Some access_token; 208 + oauth_client_id = Some client_id; 209 + oauth_client_secret = Some client_secret; 210 + } 211 + 212 + (* Check if session has signature auth *) 213 + let has_signature session = 214 + Option.is_some session.key_id && Option.is_some session.private_key_pem 215 + 216 + (* Check if session has OAuth auth *) 217 + let has_oauth session = 218 + Option.is_some session.oauth_access_token && Option.is_some session.oauth_instance 219 + 220 + (* Extract a profile name from an actor URI *) 221 + let profile_name_of_actor_uri uri = 222 + (* Convert https://example.com/users/alice to alice@example.com *) 223 + match Uri.of_string uri |> fun u -> (Uri.host u, Uri.path u) with 224 + | Some host, path -> 225 + let name = Filename.basename path in 226 + if name = "" || name = "/" then host else name ^ "@" ^ host 227 + | None, _ -> "default"
+169
lib/auth/apub_auth_session.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Session management for ActivityPub CLI with profile support. 7 + 8 + This module provides session persistence for ActivityPub actors. Sessions 9 + support two authentication methods: 10 + - HTTP signatures (key_id + private_key_pem) for ActivityPub federation 11 + - OAuth (oauth_* fields) for Mastodon REST API access 12 + 13 + Sessions are stored in profile-specific directories under 14 + [~/.config/<app_name>/profiles/<profile>/session.json]. 15 + 16 + {2 Directory Structure} 17 + 18 + {v 19 + ~/.config/<app_name>/ 20 + config.json # Stores current_profile setting 21 + profiles/ 22 + default/ 23 + session.json # Session for "default" profile 24 + alice@mastodon.social/ 25 + session.json # Session for "alice@mastodon.social" profile 26 + v} 27 + 28 + {2 Profile Usage} 29 + 30 + Profiles allow multiple ActivityPub actors to be configured simultaneously. 31 + The current profile is used by default when no profile is specified. 32 + 33 + {[ 34 + (* Setup an actor with HTTP signatures *) 35 + let session = 36 + Apub_auth_session.create ~actor_uri:"https://example.com/users/alice" 37 + ~key_id:"https://example.com/users/alice#main-key" ~private_key_pem 38 + in 39 + Apub_auth_session.save fs ~app_name:"apub" ~profile:"alice@example.com" 40 + session 41 + 42 + (* Or login via OAuth *) 43 + let session = 44 + Apub_auth_session.create_oauth ~actor_uri:"https://mastodon.social/@alice" 45 + ~instance:"mastodon.social" ~access_token ~client_id ~client_secret 46 + in 47 + Apub_auth_session.save fs ~app_name:"apub" ~profile:"alice@mastodon.social" 48 + session 49 + ]} *) 50 + 51 + (** {1 Session Type} *) 52 + 53 + type t = { 54 + actor_uri : string; 55 + (* HTTP Signature auth (optional for OAuth-only sessions) *) 56 + key_id : string option; 57 + private_key_pem : string option; 58 + (* Mastodon OAuth (optional for signature-only sessions) *) 59 + oauth_instance : string option; 60 + oauth_access_token : string option; 61 + oauth_client_id : string option; 62 + oauth_client_secret : string option; 63 + created_at : string; 64 + } 65 + (** Saved session data containing actor credentials. A session can have: 66 + - Signature auth only: key_id + private_key_pem 67 + - OAuth only: oauth_* fields 68 + - Both: for hybrid authentication *) 69 + 70 + val jsont : t Jsont.t 71 + (** JSON codec for sessions. *) 72 + 73 + (** {1 Session Creation} *) 74 + 75 + val create : actor_uri:string -> key_id:string -> private_key_pem:string -> t 76 + (** [create ~actor_uri ~key_id ~private_key_pem] creates a new signature-based 77 + session with the current timestamp. *) 78 + 79 + val create_oauth : 80 + actor_uri:string -> 81 + instance:string -> 82 + access_token:string -> 83 + client_id:string -> 84 + client_secret:string -> 85 + t 86 + (** [create_oauth ~actor_uri ~instance ~access_token ~client_id ~client_secret] 87 + creates a new OAuth-based session with the current timestamp. *) 88 + 89 + val add_oauth : 90 + t -> 91 + instance:string -> 92 + access_token:string -> 93 + client_id:string -> 94 + client_secret:string -> 95 + t 96 + (** [add_oauth session ~instance ~access_token ~client_id ~client_secret] 97 + adds OAuth credentials to an existing session for hybrid auth. *) 98 + 99 + val has_signature : t -> bool 100 + (** [has_signature session] returns true if the session has HTTP signature 101 + credentials (key_id and private_key_pem). *) 102 + 103 + val has_oauth : t -> bool 104 + (** [has_oauth session] returns true if the session has OAuth credentials 105 + (oauth_access_token and oauth_instance). *) 106 + 107 + val profile_name_of_actor_uri : string -> string 108 + (** [profile_name_of_actor_uri uri] extracts a profile name from an actor URI. 109 + For example, [https://example.com/users/alice] becomes [alice@example.com]. *) 110 + 111 + (** {1 Profile Management} *) 112 + 113 + val default_profile : string 114 + (** The default profile name (["default"]). *) 115 + 116 + val get_current_profile : Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> string 117 + (** [get_current_profile fs ~app_name] returns the current profile name. Returns 118 + {!default_profile} if no profile has been set. *) 119 + 120 + val set_current_profile : 121 + Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> string -> unit 122 + (** [set_current_profile fs ~app_name profile] sets the current profile. *) 123 + 124 + val list_profiles : Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> string list 125 + (** [list_profiles fs ~app_name] returns all profiles that have sessions. 126 + Returns profile names sorted alphabetically. *) 127 + 128 + (** {1 Directory Paths} *) 129 + 130 + val base_config_dir : 131 + Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> Eio.Fs.dir_ty Eio.Path.t 132 + (** [base_config_dir fs ~app_name] returns the base config directory for the app 133 + ([~/.config/<app_name>]), creating it if needed. *) 134 + 135 + val config_dir : 136 + Eio.Fs.dir_ty Eio.Path.t -> 137 + app_name:string -> 138 + ?profile:string -> 139 + unit -> 140 + Eio.Fs.dir_ty Eio.Path.t 141 + (** [config_dir fs ~app_name ?profile ()] returns the config directory for a 142 + profile, creating it if needed. 143 + @param profile Profile name (default: current profile) *) 144 + 145 + (** {1 Session Persistence} *) 146 + 147 + val save : 148 + Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> ?profile:string -> t -> unit 149 + (** [save fs ~app_name ?profile session] saves the session. 150 + @param profile Profile name (default: current profile) *) 151 + 152 + val load : 153 + Eio.Fs.dir_ty Eio.Path.t -> 154 + app_name:string -> 155 + ?profile:string -> 156 + unit -> 157 + t option 158 + (** [load fs ~app_name ?profile ()] loads a saved session. 159 + @param profile Profile name (default: current profile) *) 160 + 161 + val clear : 162 + Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> ?profile:string -> unit -> unit 163 + (** [clear fs ~app_name ?profile ()] removes the saved session. 164 + @param profile Profile name (default: current profile) *) 165 + 166 + (** {1 Session Utilities} *) 167 + 168 + val pp : t Fmt.t 169 + (** Pretty-print a session (does not print the private key). *)
+202
lib/auth/apub_mastodon_api.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Mastodon REST API client using OAuth bearer tokens *) 7 + 8 + (** Status visibility options *) 9 + type visibility = Public | Unlisted | Private | Direct 10 + 11 + let string_of_visibility = function 12 + | Public -> "public" 13 + | Unlisted -> "unlisted" 14 + | Private -> "private" 15 + | Direct -> "direct" 16 + 17 + (** Status response *) 18 + type status = { 19 + id : string; 20 + uri : string; 21 + url : string option; 22 + content : string; 23 + created_at : string; 24 + visibility : string; 25 + } 26 + 27 + let status_jsont = 28 + Jsont.Object.map ~kind:"MastodonStatus" 29 + (fun id uri url content created_at visibility -> 30 + { id; uri; url; content; created_at; visibility }) 31 + |> Jsont.Object.mem "id" Jsont.string ~enc:(fun s -> s.id) 32 + |> Jsont.Object.mem "uri" Jsont.string ~enc:(fun s -> s.uri) 33 + |> Jsont.Object.opt_mem "url" Jsont.string ~enc:(fun s -> s.url) 34 + |> Jsont.Object.mem "content" Jsont.string ~enc:(fun s -> s.content) 35 + |> Jsont.Object.mem "created_at" Jsont.string ~enc:(fun s -> s.created_at) 36 + |> Jsont.Object.mem "visibility" Jsont.string ~enc:(fun s -> s.visibility) 37 + |> Jsont.Object.finish 38 + 39 + (** Relationship response (for follow/unfollow) *) 40 + type relationship = { 41 + id : string; 42 + following : bool; 43 + followed_by : bool; 44 + blocking : bool; 45 + muting : bool; 46 + requested : bool; 47 + } 48 + 49 + let relationship_jsont = 50 + Jsont.Object.map ~kind:"MastodonRelationship" 51 + (fun id following followed_by blocking muting requested -> 52 + { id; following; followed_by; blocking; muting; requested }) 53 + |> Jsont.Object.mem "id" Jsont.string ~enc:(fun r -> r.id) 54 + |> Jsont.Object.mem "following" Jsont.bool ~enc:(fun r -> r.following) 55 + |> Jsont.Object.mem "followed_by" Jsont.bool ~enc:(fun r -> r.followed_by) 56 + |> Jsont.Object.mem "blocking" Jsont.bool ~enc:(fun r -> r.blocking) 57 + |> Jsont.Object.mem "muting" Jsont.bool ~enc:(fun r -> r.muting) 58 + |> Jsont.Object.mem "requested" Jsont.bool ~enc:(fun r -> r.requested) 59 + |> Jsont.Object.finish 60 + 61 + (** Helper to create authenticated headers *) 62 + let auth_headers token = 63 + Requests.Headers.empty 64 + |> Requests.Headers.bearer token 65 + 66 + (** Check response and return error if not successful *) 67 + let check_response resp = 68 + let status = Requests.Response.status_code resp in 69 + if status >= 200 && status < 300 then 70 + Ok () 71 + else 72 + let body = Requests.Response.text resp in 73 + Error (Printf.sprintf "HTTP %d: %s" status body) 74 + 75 + (** Post a new status *) 76 + let post_status requests ~instance ~token ~content 77 + ?(visibility = Public) ?in_reply_to_id ?sensitive ?spoiler_text () = 78 + let url = Printf.sprintf "https://%s/api/v1/statuses" instance in 79 + let headers = auth_headers token in 80 + let params = [ 81 + ("status", content); 82 + ("visibility", string_of_visibility visibility); 83 + ] in 84 + let params = match in_reply_to_id with 85 + | Some id -> ("in_reply_to_id", id) :: params 86 + | None -> params 87 + in 88 + let params = match sensitive with 89 + | Some true -> ("sensitive", "true") :: params 90 + | _ -> params 91 + in 92 + let params = match spoiler_text with 93 + | Some text -> ("spoiler_text", text) :: params 94 + | None -> params 95 + in 96 + let body = Requests.Body.form params in 97 + let resp = Requests.post requests ~headers ~body url in 98 + match check_response resp with 99 + | Error e -> Error e 100 + | Ok () -> Ok (Requests.Response.jsonv status_jsont resp) 101 + 102 + (** Favourite (like) a status *) 103 + let favourite requests ~instance ~token ~status_id = 104 + let url = Printf.sprintf "https://%s/api/v1/statuses/%s/favourite" instance status_id in 105 + let headers = auth_headers token in 106 + let resp = Requests.post requests ~headers url in 107 + match check_response resp with 108 + | Error e -> Error e 109 + | Ok () -> Ok (Requests.Response.jsonv status_jsont resp) 110 + 111 + (** Unfavourite a status *) 112 + let unfavourite requests ~instance ~token ~status_id = 113 + let url = Printf.sprintf "https://%s/api/v1/statuses/%s/unfavourite" instance status_id in 114 + let headers = auth_headers token in 115 + let resp = Requests.post requests ~headers url in 116 + match check_response resp with 117 + | Error e -> Error e 118 + | Ok () -> Ok (Requests.Response.jsonv status_jsont resp) 119 + 120 + (** Reblog (boost) a status *) 121 + let reblog requests ~instance ~token ~status_id = 122 + let url = Printf.sprintf "https://%s/api/v1/statuses/%s/reblog" instance status_id in 123 + let headers = auth_headers token in 124 + let resp = Requests.post requests ~headers url in 125 + match check_response resp with 126 + | Error e -> Error e 127 + | Ok () -> Ok (Requests.Response.jsonv status_jsont resp) 128 + 129 + (** Unreblog a status *) 130 + let unreblog requests ~instance ~token ~status_id = 131 + let url = Printf.sprintf "https://%s/api/v1/statuses/%s/unreblog" instance status_id in 132 + let headers = auth_headers token in 133 + let resp = Requests.post requests ~headers url in 134 + match check_response resp with 135 + | Error e -> Error e 136 + | Ok () -> Ok (Requests.Response.jsonv status_jsont resp) 137 + 138 + (** Follow an account by ID *) 139 + let follow requests ~instance ~token ~account_id = 140 + let url = Printf.sprintf "https://%s/api/v1/accounts/%s/follow" instance account_id in 141 + let headers = auth_headers token in 142 + let resp = Requests.post requests ~headers url in 143 + match check_response resp with 144 + | Error e -> Error e 145 + | Ok () -> Ok (Requests.Response.jsonv relationship_jsont resp) 146 + 147 + (** Unfollow an account by ID *) 148 + let unfollow requests ~instance ~token ~account_id = 149 + let url = Printf.sprintf "https://%s/api/v1/accounts/%s/unfollow" instance account_id in 150 + let headers = auth_headers token in 151 + let resp = Requests.post requests ~headers url in 152 + match check_response resp with 153 + | Error e -> Error e 154 + | Ok () -> Ok (Requests.Response.jsonv relationship_jsont resp) 155 + 156 + (** Look up an account by webfinger address (user@domain) *) 157 + let lookup_account requests ~instance ~token ~acct = 158 + let url = Printf.sprintf "https://%s/api/v1/accounts/lookup?acct=%s" 159 + instance (Uri.pct_encode acct) in 160 + let headers = auth_headers token in 161 + let resp = Requests.get requests ~headers url in 162 + match check_response resp with 163 + | Error e -> Error e 164 + | Ok () -> Ok (Requests.Response.jsonv Apub_mastodon_oauth.account_jsont resp) 165 + 166 + (** Search for accounts *) 167 + let search_accounts requests ~instance ~token ~query ?(limit = 10) () = 168 + let url = Printf.sprintf "https://%s/api/v1/accounts/search?q=%s&limit=%d" 169 + instance (Uri.pct_encode query) limit in 170 + let headers = auth_headers token in 171 + let resp = Requests.get requests ~headers url in 172 + match check_response resp with 173 + | Error e -> Error e 174 + | Ok () -> Ok (Requests.Response.jsonv (Jsont.list Apub_mastodon_oauth.account_jsont) resp) 175 + 176 + (** Get a status by ID *) 177 + let get_status requests ~instance ~token ~status_id = 178 + let url = Printf.sprintf "https://%s/api/v1/statuses/%s" instance status_id in 179 + let headers = auth_headers token in 180 + let resp = Requests.get requests ~headers url in 181 + match check_response resp with 182 + | Error e -> Error e 183 + | Ok () -> Ok (Requests.Response.jsonv status_jsont resp) 184 + 185 + (** Delete a status *) 186 + let delete_status requests ~instance ~token ~status_id = 187 + let url = Printf.sprintf "https://%s/api/v1/statuses/%s" instance status_id in 188 + let headers = auth_headers token in 189 + let resp = Requests.delete requests ~headers url in 190 + check_response resp 191 + 192 + (** Extract status ID from a Mastodon URL like https://instance/users/name/statuses/123 193 + or https://instance/@name/123 *) 194 + let status_id_of_url url = 195 + let uri = Uri.of_string url in 196 + let path = Uri.path uri in 197 + (* Try different URL formats *) 198 + let parts = String.split_on_char '/' path in 199 + let parts = List.filter (fun s -> s <> "") parts in 200 + match List.rev parts with 201 + | id :: _ when String.for_all (fun c -> c >= '0' && c <= '9') id -> Some id 202 + | _ -> None
+172
lib/auth/apub_mastodon_oauth.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Mastodon OAuth implementation for CLI authentication *) 7 + 8 + (** OAuth scopes for ActivityPub operations *) 9 + let scopes = 10 + "read:accounts read:statuses write:statuses read:follows write:follows \ 11 + read:favourites write:favourites" 12 + 13 + (** Client app name shown during authorization *) 14 + let client_name = "apub CLI" 15 + 16 + (** Redirect URI for out-of-band CLI authorization *) 17 + let redirect_uri = "urn:ietf:wg:oauth:2.0:oob" 18 + 19 + (** App registration response *) 20 + type app = { 21 + client_id : string; 22 + client_secret : string; 23 + vapid_key : string option; 24 + } 25 + 26 + let app_jsont = 27 + Jsont.Object.map ~kind:"MastodonApp" 28 + (fun client_id client_secret vapid_key -> 29 + { client_id; client_secret; vapid_key }) 30 + |> Jsont.Object.mem "client_id" Jsont.string ~enc:(fun a -> a.client_id) 31 + |> Jsont.Object.mem "client_secret" Jsont.string ~enc:(fun a -> a.client_secret) 32 + |> Jsont.Object.opt_mem "vapid_key" Jsont.string ~enc:(fun a -> a.vapid_key) 33 + |> Jsont.Object.finish 34 + 35 + (** Token response *) 36 + type token = { 37 + access_token : string; 38 + token_type : string; 39 + scope : string; 40 + created_at : int; 41 + } 42 + 43 + let token_jsont = 44 + Jsont.Object.map ~kind:"MastodonToken" 45 + (fun access_token token_type scope created_at -> 46 + { access_token; token_type; scope; created_at }) 47 + |> Jsont.Object.mem "access_token" Jsont.string ~enc:(fun t -> t.access_token) 48 + |> Jsont.Object.mem "token_type" Jsont.string ~enc:(fun t -> t.token_type) 49 + |> Jsont.Object.mem "scope" Jsont.string ~enc:(fun t -> t.scope) 50 + |> Jsont.Object.mem "created_at" Jsont.int ~enc:(fun t -> t.created_at) 51 + |> Jsont.Object.finish 52 + 53 + (** Account (verify_credentials response) *) 54 + type account = { 55 + id : string; 56 + username : string; 57 + acct : string; 58 + display_name : string option; 59 + url : string; 60 + } 61 + 62 + let account_jsont = 63 + Jsont.Object.map ~kind:"MastodonAccount" 64 + (fun id username acct display_name url -> 65 + { id; username; acct; display_name; url }) 66 + |> Jsont.Object.mem "id" Jsont.string ~enc:(fun a -> a.id) 67 + |> Jsont.Object.mem "username" Jsont.string ~enc:(fun a -> a.username) 68 + |> Jsont.Object.mem "acct" Jsont.string ~enc:(fun a -> a.acct) 69 + |> Jsont.Object.opt_mem "display_name" Jsont.string ~enc:(fun a -> a.display_name) 70 + |> Jsont.Object.mem "url" Jsont.string ~enc:(fun a -> a.url) 71 + |> Jsont.Object.finish 72 + 73 + (** PKCE (Proof Key for Code Exchange) *) 74 + module Pkce = struct 75 + (** Generate a random code verifier (43-128 chars, URL-safe base64) *) 76 + let generate_verifier () = 77 + (* Generate 32 random bytes (will produce 43 base64 chars) *) 78 + let bytes = Mirage_crypto_rng.generate 32 in 79 + Base64.encode_string ~pad:false ~alphabet:Base64.uri_safe_alphabet bytes 80 + 81 + (** Generate code challenge from verifier using SHA-256 *) 82 + let challenge_of_verifier verifier = 83 + let hash = Digestif.SHA256.digest_string verifier in 84 + Base64.encode_string ~pad:false ~alphabet:Base64.uri_safe_alphabet 85 + (Digestif.SHA256.to_raw_string hash) 86 + 87 + (** Generate a PKCE pair: (verifier, challenge) *) 88 + let generate () = 89 + let verifier = generate_verifier () in 90 + let challenge = challenge_of_verifier verifier in 91 + (verifier, challenge) 92 + end 93 + 94 + (** Extract instance domain from account handle (user@instance.social) *) 95 + let instance_of_account account = 96 + match String.split_on_char '@' account with 97 + | [_user; instance] -> Some instance 98 + | _ -> None 99 + 100 + (** Register a new OAuth app with the instance *) 101 + let register_app requests ~instance = 102 + let url = Printf.sprintf "https://%s/api/v1/apps" instance in 103 + let params = [ 104 + ("client_name", client_name); 105 + ("redirect_uris", redirect_uri); 106 + ("scopes", scopes); 107 + ("website", "https://github.com/avsm/apub"); 108 + ] in 109 + let body = Requests.Body.form params in 110 + let resp = Requests.post requests ~body url in 111 + let status = Requests.Response.status_code resp in 112 + if status >= 200 && status < 300 then 113 + Ok (Requests.Response.jsonv app_jsont resp) 114 + else 115 + let body = Requests.Response.text resp in 116 + Error (Printf.sprintf "Failed to register app (HTTP %d): %s" status body) 117 + 118 + (** Build the authorization URL for the user to visit *) 119 + let authorization_url ~instance ~client_id ~code_challenge = 120 + let base = Printf.sprintf "https://%s/oauth/authorize" instance in 121 + let params = [ 122 + ("response_type", "code"); 123 + ("client_id", client_id); 124 + ("redirect_uri", redirect_uri); 125 + ("scope", scopes); 126 + ("code_challenge", code_challenge); 127 + ("code_challenge_method", "S256"); 128 + ] in 129 + let query = String.concat "&" (List.map (fun (k, v) -> 130 + k ^ "=" ^ Uri.pct_encode v 131 + ) params) in 132 + base ^ "?" ^ query 133 + 134 + (** Exchange authorization code for access token *) 135 + let exchange_code requests ~instance ~client_id ~client_secret ~code ~code_verifier = 136 + let url = Printf.sprintf "https://%s/oauth/token" instance in 137 + let params = [ 138 + ("grant_type", "authorization_code"); 139 + ("code", code); 140 + ("client_id", client_id); 141 + ("client_secret", client_secret); 142 + ("redirect_uri", redirect_uri); 143 + ("code_verifier", code_verifier); 144 + ] in 145 + let body = Requests.Body.form params in 146 + let resp = Requests.post requests ~body url in 147 + let status = Requests.Response.status_code resp in 148 + if status >= 200 && status < 300 then 149 + Ok (Requests.Response.jsonv token_jsont resp) 150 + else 151 + let body = Requests.Response.text resp in 152 + Error (Printf.sprintf "Failed to exchange code (HTTP %d): %s" status body) 153 + 154 + (** Verify credentials and get account info *) 155 + let verify_credentials requests ~instance ~access_token = 156 + let url = Printf.sprintf "https://%s/api/v1/accounts/verify_credentials" instance in 157 + let headers = 158 + Requests.Headers.empty 159 + |> Requests.Headers.bearer access_token 160 + in 161 + let resp = Requests.get requests ~headers url in 162 + let status = Requests.Response.status_code resp in 163 + if status >= 200 && status < 300 then 164 + Ok (Requests.Response.jsonv account_jsont resp) 165 + else 166 + let body = Requests.Response.text resp in 167 + Error (Printf.sprintf "Failed to verify credentials (HTTP %d): %s" status body) 168 + 169 + (** Get the ActivityPub actor URI from a Mastodon account URL *) 170 + let actor_uri_of_account_url url = 171 + (* Mastodon account URLs are typically the same as actor URIs *) 172 + url
+19
lib/auth/dune
··· 1 + (library 2 + (name apub_auth) 3 + (public_name apubt.auth) 4 + (wrapped false) 5 + (libraries 6 + base64 7 + cmdliner 8 + digestif 9 + eio 10 + eio_main 11 + fmt 12 + jsont 13 + jsont.bytesrw 14 + mirage-crypto-rng 15 + mirage-crypto-rng.unix 16 + ptime.clock.os 17 + requests 18 + uri 19 + x509))
+77 -24
lib/client/apubt.ml
··· 195 195 end 196 196 197 197 module Webfinger = struct 198 + (** Convert a webfinger library Jrd to our internal Proto.Webfinger type *) 199 + let jrd_of_webfinger (jrd : Webfinger.Jrd.t) : Proto.Webfinger.t = 200 + let links = List.map (fun (link : Webfinger.Link.t) -> 201 + Proto.Webfinger.Jrd_link.make 202 + ~rel:(Webfinger.Link.rel link) 203 + ?type_:(Webfinger.Link.type_ link) 204 + ?href:(Option.map Proto.Uri.v (Webfinger.Link.href link)) 205 + ?template:( 206 + (* Try to get template from properties if it exists *) 207 + Webfinger.Link.property ~uri:"template" link 208 + ) 209 + () 210 + ) (Webfinger.Jrd.links jrd) in 211 + let aliases = match Webfinger.Jrd.aliases jrd with 212 + | [] -> None 213 + | a -> Some a 214 + in 215 + let properties = match Webfinger.Jrd.properties jrd with 216 + | [] -> None 217 + | p -> Some (List.filter_map (fun (k, v) -> 218 + match v with Some s -> Some (k, s) | None -> None 219 + ) p) 220 + in 221 + Proto.Webfinger.make 222 + ~subject:(Option.value ~default:"" (Webfinger.Jrd.subject jrd)) 223 + ?aliases 224 + ?properties 225 + ~links 226 + () 227 + 198 228 let lookup t acct = 199 - (* Normalize account: remove acct: prefix if present *) 200 - let acct = 201 - if String.starts_with ~prefix:"acct:" acct then 202 - String.sub acct 5 (String.length acct - 5) 203 - else acct 229 + (* Parse the account string into an Acct.t *) 230 + let acct_uri = 231 + (* Handle both "user@domain" and "acct:user@domain" formats *) 232 + let acct_str = 233 + if String.starts_with ~prefix:"acct:" acct then acct 234 + else "acct:" ^ acct 235 + in 236 + match Webfinger.Acct.of_string acct_str with 237 + | Ok a -> a 238 + | Error e -> raise (E (Webfinger_error (Webfinger.error_to_string e))) 204 239 in 205 - (* Extract domain from user@domain *) 206 - let domain = 207 - match String.split_on_char '@' acct with 208 - | [_; domain] -> domain 209 - | _ -> raise (E (Webfinger_error ("Invalid account format: " ^ acct))) 240 + (* Use the webfinger library's query function *) 241 + match Webfinger.query_acct t.requests acct_uri () with 242 + | Ok jrd -> jrd_of_webfinger jrd 243 + | Error e -> raise (E (Webfinger_error (Webfinger.error_to_string e))) 244 + 245 + (** Look up using webfinger library and return the raw Webfinger.Jrd.t *) 246 + let lookup_raw t acct = 247 + let acct_uri = 248 + let acct_str = 249 + if String.starts_with ~prefix:"acct:" acct then acct 250 + else "acct:" ^ acct 251 + in 252 + match Webfinger.Acct.of_string acct_str with 253 + | Ok a -> a 254 + | Error e -> raise (E (Webfinger_error (Webfinger.error_to_string e))) 210 255 in 211 - (* Build Webfinger URL *) 212 - let url = Printf.sprintf "https://%s/.well-known/webfinger?resource=acct:%s" domain acct in 213 - let headers = 214 - Requests.Headers.empty 215 - |> Requests.Headers.add `Accept "application/jrd+json, application/json" 216 - in 217 - let resp = Requests.get t.requests ~headers url in 218 - check_response resp; 219 - Requests.Response.jsonv Proto.Webfinger.jsont resp 256 + match Webfinger.query_acct t.requests acct_uri () with 257 + | Ok jrd -> jrd 258 + | Error e -> raise (E (Webfinger_error (Webfinger.error_to_string e))) 220 259 221 260 let actor_uri jrd = 222 261 match Proto.Webfinger.links jrd with 223 262 | None -> None 224 263 | Some links -> 225 264 List.find_map (fun link -> 226 - if Proto.Webfinger.Jrd_link.rel link = "self" then 265 + if Proto.Webfinger.Jrd_link.rel link = Webfinger.Rel.activitypub then 227 266 match Proto.Webfinger.Jrd_link.type_ link with 228 267 | Some t when String.equal t "application/activity+json" -> 229 268 Proto.Webfinger.Jrd_link.href link ··· 233 272 else None 234 273 ) links 235 274 275 + (** Extract ActivityPub actor URI from a raw Webfinger.Jrd.t *) 276 + let actor_uri_raw (jrd : Webfinger.Jrd.t) : Proto.Uri.t option = 277 + (* Look for self link with ActivityPub media type *) 278 + match Webfinger.Jrd.find_link ~rel:Webfinger.Rel.activitypub jrd with 279 + | Some link -> 280 + (match Webfinger.Link.type_ link with 281 + | Some t when String.equal t "application/activity+json" -> 282 + Option.map Proto.Uri.v (Webfinger.Link.href link) 283 + | Some t when String.starts_with ~prefix:"application/ld+json" t -> 284 + Option.map Proto.Uri.v (Webfinger.Link.href link) 285 + | _ -> None) 286 + | None -> None 287 + 236 288 let profile_page jrd = 237 289 match Proto.Webfinger.links jrd with 238 290 | None -> None 239 291 | Some links -> 240 292 List.find_map (fun link -> 241 - if Proto.Webfinger.Jrd_link.rel link = "http://webfinger.net/rel/profile-page" then 293 + if Proto.Webfinger.Jrd_link.rel link = Webfinger.Rel.profile then 242 294 Proto.Webfinger.Jrd_link.href link 243 295 else None 244 296 ) links ··· 248 300 | None -> None 249 301 | Some links -> 250 302 List.find_map (fun link -> 251 - if Proto.Webfinger.Jrd_link.rel link = "http://ostatus.org/schema/1.0/subscribe" then 303 + if Proto.Webfinger.Jrd_link.rel link = Webfinger.Rel.subscribe then 252 304 Proto.Webfinger.Jrd_link.template link 253 305 else None 254 306 ) links ··· 327 379 Http.get_typed t Proto.Actor.jsont uri 328 380 329 381 let lookup t acct = 330 - let jrd = Webfinger.lookup t acct in 331 - match Webfinger.actor_uri jrd with 382 + (* Use the raw webfinger lookup for efficiency - avoids converting to Proto.Webfinger *) 383 + let jrd = Webfinger.lookup_raw t acct in 384 + match Webfinger.actor_uri_raw jrd with 332 385 | Some uri -> fetch t uri 333 386 | None -> raise (E (Webfinger_error "No ActivityPub actor link in Webfinger response")) 334 387
+31 -3
lib/client/apubt.mli
··· 171 171 (** {1 Webfinger Discovery} *) 172 172 173 173 (** Webfinger actor discovery per RFC 7033. 174 - @see <https://www.rfc-editor.org/rfc/rfc7033> *) 174 + 175 + This module uses the [webfinger] library for robust RFC 7033/7565 compliance 176 + with proper acct URI handling and percent-encoding. See the 177 + {{:https://swicg.github.io/activitypub-webfinger/}ActivityPub WebFinger spec} 178 + for details on how WebFinger is used with ActivityPub. 179 + 180 + @see <https://www.rfc-editor.org/rfc/rfc7033> RFC 7033 WebFinger 181 + @see <https://www.rfc-editor.org/rfc/rfc7565> RFC 7565 acct URI *) 175 182 module Webfinger : sig 176 183 val lookup : t -> string -> Proto.Webfinger.t 177 184 (** [lookup client acct] performs a Webfinger lookup for the given account. 178 185 179 186 The [acct] can be in the form "user@domain" or "acct:user@domain". 187 + Uses the [webfinger] library for proper RFC 7565 acct URI handling. 188 + 189 + @raise E on lookup failure *) 190 + 191 + val lookup_raw : t -> string -> Webfinger.Jrd.t 192 + (** [lookup_raw client acct] performs a Webfinger lookup returning the raw JRD. 193 + 194 + This is more efficient when you only need to extract specific fields 195 + and don't need the full {!Proto.Webfinger.t} type. 180 196 181 197 @raise E on lookup failure *) 182 198 183 199 val actor_uri : Proto.Webfinger.t -> Proto.Uri.t option 184 200 (** [actor_uri jrd] extracts the ActivityPub actor URI from a Webfinger response. 185 201 186 - Looks for a link with [rel="self"] and [type="application/activity+json"]. *) 202 + Looks for a link with [rel="self"] and [type="application/activity+json"] 203 + or [type="application/ld+json; profile=..."]. 204 + 205 + Per the ActivityPub WebFinger spec, publishers SHOULD include exactly one 206 + such link. *) 207 + 208 + val actor_uri_raw : Webfinger.Jrd.t -> Proto.Uri.t option 209 + (** [actor_uri_raw jrd] extracts the ActivityPub actor URI from a raw JRD. 210 + 211 + More efficient variant that works directly with {!Webfinger.Jrd.t}. *) 187 212 188 213 val profile_page : Proto.Webfinger.t -> Proto.Uri.t option 189 - (** [profile_page jrd] extracts the HTML profile page URI from a Webfinger response. *) 214 + (** [profile_page jrd] extracts the HTML profile page URI from a Webfinger response. 215 + 216 + Looks for [rel="http://webfinger.net/rel/profile-page"]. *) 190 217 191 218 val subscribe_template : Proto.Webfinger.t -> string option 192 219 (** [subscribe_template jrd] extracts the subscribe/follow template URI. 193 220 221 + Looks for [rel="http://ostatus.org/schema/1.0/subscribe"]. 194 222 This is used for remote follow buttons. The template contains [{uri}] 195 223 which should be replaced with the actor to follow. *) 196 224 end
+1 -1
lib/client/dune
··· 1 1 (library 2 2 (name apubt) 3 3 (public_name apubt) 4 - (libraries apubt_proto eio jsont jsont.bytesrw ptime.clock.os requests x509)) 4 + (libraries apubt_proto eio jsont jsont.bytesrw ptime.clock.os requests webfinger x509))
+55 -8
lib/proto/apubt_proto.ml
··· 809 809 ?sensitive:bool -> 810 810 ?conversation:Uri.t -> 811 811 ?audience:Recipient.t list -> 812 + ?location:Link_or_uri.t -> 813 + ?preview:Link_or_uri.t -> 812 814 unit -> t 815 + (** Create a new Object. *) 813 816 814 817 val context : t -> Context.t option 815 818 val id : t -> Uri.t option ··· 841 844 val conversation : t -> Uri.t option 842 845 val audience : t -> Recipient.t list option 843 846 847 + val location : t -> Link_or_uri.t option 848 + (** [location t] returns the physical or logical location associated with the object. *) 849 + 850 + val preview : t -> Link_or_uri.t option 851 + (** [preview t] returns a preview of the object, typically a smaller version. *) 852 + 844 853 val jsont : t Jsont.t 854 + (** JSON type for Objects. *) 845 855 end = struct 846 856 type t = { 847 857 context : Context.t option; ··· 873 883 sensitive : bool option; 874 884 conversation : Uri.t option; 875 885 audience : Recipient.t list option; 886 + location : Link_or_uri.t option; 887 + preview : Link_or_uri.t option; 876 888 } 877 889 878 890 let make ?context ?id ~type_ ?name ?summary ?content ?media_type ?url 879 891 ?attributed_to ?in_reply_to ?published ?updated ?deleted ?to_ ?cc 880 892 ?bto ?bcc ?replies ?attachment ?tag ?generator ?icon ?image 881 - ?start_time ?end_time ?duration ?sensitive ?conversation ?audience () = 893 + ?start_time ?end_time ?duration ?sensitive ?conversation ?audience 894 + ?location ?preview () = 882 895 { context; id; type_; name; summary; content; media_type; url; 883 896 attributed_to; in_reply_to; published; updated; deleted; 884 897 to_; cc; bto; bcc; replies; attachment; tag; generator; 885 898 icon; image; start_time; end_time; duration; sensitive; 886 - conversation; audience } 899 + conversation; audience; location; preview } 887 900 888 901 let context t = t.context 889 902 let id t = t.id ··· 914 927 let sensitive t = t.sensitive 915 928 let conversation t = t.conversation 916 929 let audience t = t.audience 930 + let location t = t.location 931 + let preview t = t.preview 917 932 918 933 let jsont = 919 934 Jsont.Object.map ~kind:"Object" 920 935 (fun context id type_ name summary content media_type url attributed_to 921 936 in_reply_to published updated deleted to_ cc bto bcc replies 922 937 attachment tag generator icon image start_time end_time duration 923 - sensitive conversation audience -> 938 + sensitive conversation audience location preview -> 924 939 { context; id; type_; name; summary; content; media_type; url; 925 940 attributed_to; in_reply_to; published; updated; deleted; 926 941 to_; cc; bto; bcc; replies; attachment; tag; generator; 927 942 icon; image; start_time; end_time; duration; sensitive; 928 - conversation; audience }) 943 + conversation; audience; location; preview }) 929 944 |> Jsont.Object.opt_mem "@context" Context.jsont ~enc:context 930 945 |> Jsont.Object.opt_mem "id" Uri.jsont ~enc:id 931 946 |> Jsont.Object.mem "type" Object_type.jsont ~enc:type_ ··· 959 974 |> Jsont.Object.opt_mem "sensitive" Jsont.bool ~enc:sensitive 960 975 |> Jsont.Object.opt_mem "conversation" Uri.jsont ~enc:conversation 961 976 |> Jsont.Object.opt_mem "audience" (one_or_many Recipient.jsont) ~enc:audience 977 + |> Jsont.Object.opt_mem "location" Link_or_uri.jsont ~enc:location 978 + |> Jsont.Object.opt_mem "preview" Link_or_uri.jsont ~enc:preview 962 979 |> Jsont.Object.finish 963 980 end 964 981 ··· 1175 1192 ?published:Datetime.t -> 1176 1193 ?updated:Datetime.t -> 1177 1194 ?summary:string -> 1195 + ?one_of:Object_ref.t list -> 1196 + ?any_of:Object_ref.t list -> 1197 + ?closed:Datetime.t -> 1178 1198 unit -> t 1199 + (** Create a new Activity. 1200 + 1201 + The [one_of], [any_of], and [closed] fields are only used for Question 1202 + activities (polls). Use [one_of] for single-choice polls and [any_of] 1203 + for multiple-choice polls. *) 1179 1204 1180 1205 val context : t -> Context.t option 1181 1206 val id : t -> Uri.t option ··· 1194 1219 val updated : t -> Datetime.t option 1195 1220 val summary : t -> string option 1196 1221 1222 + val one_of : t -> Object_ref.t list option 1223 + (** [one_of t] returns single-choice poll options for Question activities. *) 1224 + 1225 + val any_of : t -> Object_ref.t list option 1226 + (** [any_of t] returns multiple-choice poll options for Question activities. *) 1227 + 1228 + val closed : t -> Datetime.t option 1229 + (** [closed t] returns when the poll was closed, for Question activities. *) 1230 + 1197 1231 val jsont : t Jsont.t 1232 + (** JSON type for Activities. *) 1198 1233 end = struct 1199 1234 type t = { 1200 1235 context : Context.t option; ··· 1213 1248 published : Datetime.t option; 1214 1249 updated : Datetime.t option; 1215 1250 summary : string option; 1251 + one_of : Object_ref.t list option; 1252 + any_of : Object_ref.t list option; 1253 + closed : Datetime.t option; 1216 1254 } 1217 1255 1218 1256 let make ?context ?id ~type_ ~actor ?object_ ?target ?result ?origin 1219 - ?instrument ?to_ ?cc ?bto ?bcc ?published ?updated ?summary () = 1257 + ?instrument ?to_ ?cc ?bto ?bcc ?published ?updated ?summary 1258 + ?one_of ?any_of ?closed () = 1220 1259 { context; id; type_; actor; object_; target; result; origin; 1221 - instrument; to_; cc; bto; bcc; published; updated; summary } 1260 + instrument; to_; cc; bto; bcc; published; updated; summary; 1261 + one_of; any_of; closed } 1222 1262 1223 1263 let context t = t.context 1224 1264 let id t = t.id ··· 1236 1276 let published t = t.published 1237 1277 let updated t = t.updated 1238 1278 let summary t = t.summary 1279 + let one_of t = t.one_of 1280 + let any_of t = t.any_of 1281 + let closed t = t.closed 1239 1282 1240 1283 let jsont = 1241 1284 Jsont.Object.map ~kind:"Activity" 1242 1285 (fun context id type_ actor object_ target result origin instrument 1243 - to_ cc bto bcc published updated summary -> 1286 + to_ cc bto bcc published updated summary one_of any_of closed -> 1244 1287 { context; id; type_; actor; object_; target; result; origin; 1245 - instrument; to_; cc; bto; bcc; published; updated; summary }) 1288 + instrument; to_; cc; bto; bcc; published; updated; summary; 1289 + one_of; any_of; closed }) 1246 1290 |> Jsont.Object.opt_mem "@context" Context.jsont ~enc:context 1247 1291 |> Jsont.Object.opt_mem "id" Uri.jsont ~enc:id 1248 1292 |> Jsont.Object.mem "type" Activity_type.jsont ~enc:type_ ··· 1259 1303 |> Jsont.Object.opt_mem "published" Datetime.jsont ~enc:published 1260 1304 |> Jsont.Object.opt_mem "updated" Datetime.jsont ~enc:updated 1261 1305 |> Jsont.Object.opt_mem "summary" Jsont.string ~enc:summary 1306 + |> Jsont.Object.opt_mem "oneOf" (Jsont.list Object_ref.jsont) ~enc:one_of 1307 + |> Jsont.Object.opt_mem "anyOf" (Jsont.list Object_ref.jsont) ~enc:any_of 1308 + |> Jsont.Object.opt_mem "closed" Datetime.jsont ~enc:closed 1262 1309 |> Jsont.Object.finish 1263 1310 end 1264 1311
+25 -1
lib/proto/apubt_proto.mli
··· 362 362 ?sensitive:bool -> 363 363 ?conversation:Uri.t -> 364 364 ?audience:Recipient.t list -> 365 + ?location:Link_or_uri.t -> 366 + ?preview:Link_or_uri.t -> 365 367 unit -> t 366 368 (** Create a new Object. *) 367 369 ··· 394 396 val sensitive : t -> bool option 395 397 val conversation : t -> Uri.t option 396 398 val audience : t -> Recipient.t list option 399 + 400 + val location : t -> Link_or_uri.t option 401 + (** [location t] returns the physical or logical location associated with the object. *) 402 + 403 + val preview : t -> Link_or_uri.t option 404 + (** [preview t] returns a preview of the object, typically a smaller version. *) 397 405 398 406 val jsont : t Jsont.t 399 407 (** JSON type for Objects. *) ··· 472 480 ?published:Datetime.t -> 473 481 ?updated:Datetime.t -> 474 482 ?summary:string -> 483 + ?one_of:Object_ref.t list -> 484 + ?any_of:Object_ref.t list -> 485 + ?closed:Datetime.t -> 475 486 unit -> t 476 - (** Create a new Activity. *) 487 + (** Create a new Activity. 488 + 489 + The [one_of], [any_of], and [closed] fields are only used for Question 490 + activities (polls). Use [one_of] for single-choice polls and [any_of] 491 + for multiple-choice polls. *) 477 492 478 493 val context : t -> Context.t option 479 494 val id : t -> Uri.t option ··· 491 506 val published : t -> Datetime.t option 492 507 val updated : t -> Datetime.t option 493 508 val summary : t -> string option 509 + 510 + val one_of : t -> Object_ref.t list option 511 + (** [one_of t] returns single-choice poll options for Question activities. *) 512 + 513 + val any_of : t -> Object_ref.t list option 514 + (** [any_of t] returns multiple-choice poll options for Question activities. *) 515 + 516 + val closed : t -> Datetime.t option 517 + (** [closed t] returns when the poll was closed, for Question activities. *) 494 518 495 519 val jsont : t Jsont.t 496 520 (** JSON type for Activities. *)
+302
spec/activitypub-webfinger.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + 4 + <head> 5 + <meta charset="utf-8" /> 6 + <title>ActivityPub and WebFinger</title> 7 + <script src="https://www.w3.org/Tools/respec/respec-w3c" class="remove" defer></script> 8 + <script class="remove"> 9 + // All config options at https://respec.org/docs/ 10 + var respecConfig = { 11 + editors: [{ name: "a", url: "https://trwnh.com" }, { name: "Evan Prodromou", url: "https://evanp.me/" }], 12 + specStatus: "CG-FINAL", 13 + latestVersion: "https://www.w3.org/community/reports/socialcg/apwf/", 14 + xref: "web-platform", 15 + group: "socialcg", 16 + shortName: "apwf", 17 + github: "swicg/activitypub-webfinger", 18 + }; 19 + </script> 20 + </head> 21 + 22 + <body> 23 + <section id="abstract"> 24 + <p>Identifiers in ActivityPub tend to be HTTPS URIs. The use of WebFinger (as defined in [[RFC7033]]) allows for discovery of an actor's identifier given a username and a hostname, which may be more socially salient or otherwise easier to communicate across various contexts and media. The username and hostname are resolved at the WebFinger endpoint of the hostname in order to discover a link to an actor associated with the user's account, and that actor similarly can be back-linked to the username and hostname.</p> 25 + </section> 26 + <section id="sotd"> 27 + </section> 28 + <section class="informative" id="motivation"> 29 + <h2>Motivation</h2> 30 + <p>Consider an HTTPS URI of the form <code>https://social.example/actors/9c5b94b1-35ad-49bb-b118-8e8fc24abf80</code> being used as an identifier for an actor associated with a user account. Communicating this digitally may be done by simply using the HTTPS URI as-is, as a hyperlink reference. However, communicating this verbally or in a space-constrained visual format can be difficult. WebFinger allows communicating aliases of the form <code>alyssa@social.example</code>, which are easier to work with in the previously-cited cases.</p> 31 + <p>Additional benefits of using WebFinger include smoothing over the differences between varying actor URI schemas. Different softwares may provide human-friendly URLs for an actor's profile, but these URLs may take several different forms:</p> 32 + <ul> 33 + <li><code>https://social.example/alyssa</code></li> 34 + <li><code>https://social.example/@alyssa</code></li> 35 + <li><code>https://social.example/~alyssa</code></li> 36 + <li><code>https://social.example/u/alyssa</code></li> 37 + <li>...and so on.</li> 38 + </ul> 39 + <p>Conventionally, people can be identified by their user@domain address, while documents can be identified by their HTTPS location.</p> 40 + </section> 41 + <section class="normative" id="discovery"> 42 + <h2>Discovery</h2> 43 + <p>Discovery can occur in one of two directions:</p> 44 + <ul> 45 + <li>Given a WebFinger address, one can resolve it to an actor document;</li> 46 + <li>Given an actor document with a preferredUsername, one can reconstruct a WebFinger address that should link back to the same actor document.</li> 47 + </ul> 48 + <p>The former will be referred to as "forward discovery" and the latter will be referred to as "reverse discovery".</p> 49 + <section id="forward-discovery"> 50 + <h3>Forward discovery of an actor document given a WebFinger address</h3> 51 + <p>Given a username and hostname in the form <code>user@domain</code>:</p> 52 + <ol> 53 + <li>Construct an <code>acct:</code> URI of the form <code>acct:user@domain</code> (as defined in [[RFC7565]])</li> 54 + <li>Make an HTTP GET request to that hostname's WebFinger well-known endpoint, using the <code>acct:</code> URI as the value of the <code>resource</code> query parameter (as described in [[RFC7033]])</li> 55 + </ol> 56 + <p>For example, the WebFinger address <code>alyssa@social.example</code> can be resolved as a resource by making an HTTP GET request for <code>https://social.example/.well-known/webfinger?resource=acct:alyssa@social.example</code> (which is <code>https://social.example/.well-known/webfinger?resource=acct:alyssa%40social.example</code> when percent-encoded). This request MAY result in an HTTP 3xx redirect, in which case the redirect MUST be followed to the <code>Location</code> header's value, which MUST be an <code>https:</code> URI per [[RFC7033]]. (Subsequent redirects SHOULD be followed, up until a maximum redirect limit at the discretion of the requester.) The final request MUST return a JRD (JSON Resource Descriptor, as defined in [[RFC6415]]) with <code>application/jrd+json</code> as the content type (assuming no specified <code>Accept</code> header).</p> 57 + <p>The WebFinger request and response may look like this:</p> 58 + <pre class="http example" title="Sample WebFinger request and JRD response"> 59 + GET /.well-known/webfinger?resource=acct:alyssa%40social.example HTTP/1.1 60 + Host: social.example 61 + 62 + HTTP/1.1 307 Temporary Redirect 63 + Location: https://social.example/jrd/alyssa 64 + 65 + GET /jrd/alyssa HTTP/1.1 66 + Host: social.example 67 + 68 + HTTP/1.1 200 OK 69 + Content-Type: application/jrd+json 70 + 71 + { 72 + "subject": "acct:alyssa@social.example", 73 + "aliases": [ 74 + "https://social.example/@alyssa", 75 + "https://social.example/actors/9c5b94b1-35ad-49bb-b118-8e8fc24abf80" 76 + ], 77 + "links": [ 78 + { 79 + "rel": "http://webfinger.net/rel/profile-page", 80 + "type": "text/html", 81 + "href": "https://social.example/@alyssa" 82 + }, 83 + { 84 + "rel": "self", 85 + "type": "application/activity+json", 86 + "href": "https://social.example/actors/9c5b94b1-35ad-49bb-b118-8e8fc24abf80" 87 + } 88 + ] 89 + } 90 + </pre> 91 + <p>At this point, you can parse for the <code>href</code> of the element of <code>links</code> that has a <code>rel</code> of <code>self</code> and a <code>type</code> of either <code>application/ld+json; profile="https://www.w3.org/ns/activitystreams"</code> or <code>application/activity+json</code> (depending on the implementation). See <a href="#link"></a> for more information about this.</p> 92 + </section> 93 + <section id="reverse-discovery"> 94 + <h3>Reverse discovery of a WebFinger address given an actor document</h3> 95 + <p>Given an actor with an <code>id</code> and a <code>preferredUsername</code>:</p> 96 + <ol> 97 + <li>Take the hostname of the <code>id</code> to discover the WebFinger domain</li> 98 + <li>Combine the <code>preferredUsername</code> and the WebFinger domain in order to form a WebFinger address</li> 99 + <li>Verify that this WebFinger address links back to the same actor when performing discovery as described in <a href="#forward-discovery"></a></li> 100 + <li>Optionally: If the JRD from the previous step has a <code>subject</code> and it contains an <code>acct:</code> URI different from the one you constructed, perform a verification discovery against that <code>acct:</code> URI afterward. (In such cases, the <code>subject</code> of the JRD denotes the expected canonical identifier.)</li> 101 + </ol> 102 + <p>For example, given an actor document at <code>https://activitypub.example.com/actor/1</code> like so:</p> 103 + <pre class="json example" title="Sample actor document"> 104 + { 105 + "@context": "https://www.w3.org/ns/activitystreams", 106 + "id": "https://activitypub.example.com/actor/1", 107 + "preferredUsername": "alice", 108 + "name": "Alice P. Hacker" 109 + } 110 + </pre> 111 + <p>The reverse discovery process would extract <code>alice</code> and <code>activitypub.example.com</code>, construct the <code>acct:</code> URI <code>acct:alice@activitypub.example.com</code>, then request <code>https://activitypub.example.com/.well-known/webfinger?resource=acct:alice@activitypub.example.com</code> like so:</p> 112 + <pre class="http example" title="Verifying the constructed WebFinger address"> 113 + GET /.well-known/webfinger?resource=acct:alice@activitypub.example.com HTTP/1.1 114 + Host: activitypub.example.com 115 + 116 + HTTP/1.1 200 OK 117 + Content-Type: application/jrd+json 118 + 119 + { 120 + "subject": "acct:alice@example.com", 121 + "aliases": [ 122 + "https://example.com/@alice", 123 + "https://activitypub.example.com/actors/1" 124 + ], 125 + "links": [ 126 + { 127 + "rel": "http://webfinger.net/rel/profile-page", 128 + "type": "text/html", 129 + "href": "https://example.com/@alice" 130 + }, 131 + { 132 + "rel": "self", 133 + "type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", 134 + "href": "https://activitypub.example.com/actors/1" 135 + } 136 + ] 137 + } 138 + </pre> 139 + <p>At this point, we have validated that <code>alice@activitypub.example.com</code> links back to our actor document, but we can optionally verify that the canonical WebFinger address of <code>alice@example.com</code> also links back to the same actor document:</p> 140 + <pre class="http example" title="Verifying the canonical WebFinger address discovered from the constructed WebFinger address"> 141 + GET /.well-known/webfinger?resource=acct:alice@example.com HTTP/1.1 142 + Host: example.com 143 + 144 + HTTP/1.1 307 Temporary Redirect 145 + Location: https://activitypub.example.com/.well-known/webfinger?resource=acct:alice@example.com 146 + 147 + GET /.well-known/webfinger?resource=acct:alice@example.com HTTP/1.1 148 + Host: activitypub.example.com 149 + 150 + HTTP/1.1 200 OK 151 + Content-Type: application/jrd+json 152 + 153 + { 154 + "subject": "acct:alice@example.com", 155 + "aliases": [ 156 + "https://example.com/@alice", 157 + "https://activitypub.example.com/actors/1" 158 + ], 159 + "links": [ 160 + { 161 + "rel": "http://webfinger.net/rel/profile-page", 162 + "type": "text/html", 163 + "href": "https://example.com/@alice" 164 + }, 165 + { 166 + "rel": "self", 167 + "type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", 168 + "href": "https://activitypub.example.com/actors/1" 169 + } 170 + ] 171 + } 172 + </pre> 173 + </section> 174 + </section> 175 + <section class="normative" id="encoding"> 176 + <h2>Encoding</h2> 177 + <p>To ensure smooth operation of the WebFinger discovery flows, identifiers and responses should follow certain guidelines for encoding.</p> 178 + <section class="normative" id="names"> 179 + <h3>Limitations on usernames and hostnames</h3> 180 + <p>The <code>acct:</code> URI scheme is defined in [[RFC7565]], which contains ABNF ([[RFC5234]]) for allowed characters (inheriting from [[RFC3986]] as well):</p> 181 + <pre class="abnf" title="acct: URI ABNF"> 182 + acctURI = "acct" ":" userpart "@" host 183 + userpart = unreserved / sub-delims 184 + 0*( unreserved / pct-encoded / sub-delims ) 185 + ; userpart regex: [A-Za-z0-9\-\.\_\~\!\$\&\'\(\)\*\+\,\;\=](?:[A-Za-z0-9\-\.\_\~\!\$\&\'\(\)\*\+\,\;\=]|(?:%[0-9A-Fa-f]{2}))* 186 + unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" 187 + sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" 188 + pct-encoded = "%" HEXDIG HEXDIG 189 + 190 + host = IP-literal / IPv4address / reg-name 191 + reg-name = *( unreserved / pct-encoded / sub-delims ) 192 + ; reg-name regex: (?:[A-Za-z0-9\-\.\_\~\!\$\&\'\(\)\*\+\,\;\=]|(?:%[0-9A-Fa-f]{2}))* 193 + </pre> 194 + <p>Further restrictions are specified in [[RFC7565]]:</p> 195 + <ul> 196 + <li>Per [[RFC3986]] Section 6.2.2.1 "Case Normalization", the scheme and host are case-insensitive and SHOULD therefore be normalized to lowercase.</li> 197 + <li>Per [[RFC3986]] Section 6.2.2.2 "Percent-Encoding Normalization", any <code>unreserved</code> characters SHOULD be decoded if encountered as a percent-encoded octet.</li> 198 + <li>Before percent-encoding anything, the userpart MUST consist only of Unicode code points that are part of the [[RFC7564]] PRECIS <code>IdentifierClass</code> 199 + </li> 200 + <li>Before percent-encoding anything, the hostname MUST conform to IDNA rules specified in [[RFC5982]] and [[RFC5980]] for A-label (ASCII-Compatible Encoding, or Punycode)</li> 201 + </ul> 202 + <section class="informative" id="rules"> 203 + <h4>Current implementation rules</h4> 204 + <p>Note that while there are several symbols allowed in the userpart, the de facto limits set by some current implementers are much more restrictive.</p> 205 + <p>At the time of this writing, Mastodon enforces the following rules:</p> 206 + <ul> 207 + <li>The username must be at least one character</li> 208 + <li>ASCII alphanumeric characters (<code>A</code> through <code>Z</code>, <code>a</code> through <code>z</code>, <code>0</code> through <code>9</code>) and underscores (<code>_</code>) are generally allowed anywhere in the username</li> 209 + <li>Dots (<code>.</code>) and dashes (<code>-</code>) are allowed in the middle of a username, but not as the first character or the last character</li> 210 + <li>All other symbols are disallowed</li> 211 + <li>Usernames are case-insensitive</li> 212 + </ul> 213 + <p>In other words, Mastodon will accept the regular expression or the following ABNF:</p> 214 + <pre class="abnf" title="Mastodon username ABNF"> 215 + ; As a regular expression, this can be expressed as follows: 216 + ; /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i 217 + 218 + username = word 219 + *( rest ) 220 + word = ALPHA / DIGIT / "_" 221 + rest = *( extended ) 222 + word 223 + extended = word / "." / "-" 224 + </pre> 225 + <p>Similarly at the time of this writing, Misskey is subject to the following limitations:</p> 226 + <ul> 227 + <li>Usernames are limited to 128 characters</li> 228 + <li>Hostnames are limited to 128 characters</li> 229 + </ul> 230 + </section> 231 + <section class="normative" id="usernames"> 232 + <h4>Recommendations for handling usernames</h4> 233 + <ul> 234 + <li>Implementers SHOULD treat local usernames as case-insensitive.</li> 235 + <li>Implementers SHOULD NOT assume case insensitivity for external usernames.</li> 236 + <li>Implementers SHOULD NOT treat usernames as stable identifiers that will always map to the same actor, and SHOULD use the actor <code>id</code> in any references to an actor. (Implementers that currently treat usernames as canonical identifiers SHOULD take steps to avoid doing so in the future.)</li> 237 + <li>Implementers SHOULD limit the length of local usernames. The exact limit is not specified, but it is noteworthy that similar systems such as email often limit the localpart to 64 characters (per [[RFC2821]]).</li> 238 + <li>Implementers SHOULD support remote usernames containing valid characters per [[RFC7565]]. For short-term compatibility, implementers SHOULD NOT use characters other than alphanumeric (<code>A-Z, a-z, 0-9</code>) and underscores (<code>_</code>).</li> 239 + </ul> 240 + </section> 241 + </section> 242 + <section class="normative" id="link"> 243 + <h3>Establishing a link between the WebFinger resource and the actor document</h3> 244 + <p>As discussed in <a href="#forward-discovery"></a>, the link to the actor associated with a given WebFinger address will have the following qualifiers:</p> 245 + <ul> 246 + <li><code>rel</code> MUST be <code>self</code></li> 247 + <li><code>type</code> MUST be either <code>application/activity+json</code> or <code>application/ld+json; profile="https://www.w3.org/ns/activitystreams"</code></li> 248 + </ul> 249 + <p>Publishers SHOULD include only one such link.</p> 250 + <p>Due to the prevailing use of WebFinger addresses as canonical primary identifiers for users, implementations that require WebFinger for compatibility will often also deduplicate actors based on the WebFinger address. Therefore, it is generally expected that there is only one <code>self</code> link to an Activity Streams document, in a unary relationship. However, some implementations do not follow this expectation, and there might be multiple links to ActivityStreams documents for the same WebFinger <code>acct:</code> resource. In such cases, one of the following strategies may be employed:</p> 251 + <ul> 252 + <li>Take the first matching entry in `links`</li> 253 + <li>Deduplicate all matching entries in `links` by their `href`</li> 254 + <li>Check for additional information, such as the presence of `properties` which may give further hints as to which entry of `links` is appropriate to follow</li> 255 + </ul> 256 + </section> 257 + </section> 258 + <section class="informative" id="other"> 259 + <h3>Other uses of WebFinger</h3> 260 + <p>Aside from the self-link to the associated actor, resolving a WebFinger query may expose some other links of potential interest. The following link relations are currently common among WebFinger implementers, and are recommended for use especially when the actor document is not publicly available:</p> 261 + <ul> 262 + <li><code>http://webfinger.net/rel/profile-page</code> (for quickly getting the HTML profile page of a user without resolving their actor document and checking for the <code>url</code>)</li> 263 + <li><code>http://webfinger.net/rel/avatar</code> (for quickly getting the avatar of a user without resolving their actor document and checking for the <code>icon</code>)</li> 264 + </ul> 265 + <p>The following link relations are less common, but offer useful information to ActivityPub implementers:</p> 266 + <ul> 267 + <li><code>http://ostatus.org/schema/1.0/subscribe</code> (used to power features like Mastodon's remote follow buttons)</li> 268 + <li><code>http://schemas.google.com/g/2010#updates-from</code> (used by some implementations to link to an Atom feed)</li> 269 + <li><code>feed</code> (used by some implementations to link to one or more feeds; feeds can be disambiguated by checking <code>type</code> and/or <code>title</code> properties of the link)</li> 270 + <li><code>http://a9.com/-/spec/opensearch/1.1/</code> (for custom search bars)</li> 271 + </ul> 272 + <p>Also uncommon but supported by at least one implementation (WordPress) is the ability to query non-actor, non-user resources via WebFinger. The following link relations are exposed:</p> 273 + <ul> 274 + <li><code>shortlink</code></li> 275 + <li><code>author</code></li> 276 + <li><code>alternate</code></li> 277 + <li><code>license</code></li> 278 + <li><code>canonical</code></li> 279 + <li><code>webmention</code></li> 280 + </ul> 281 + </section> 282 + <section class="informative" id="security"> 283 + <h2>Security Considerations</h2> 284 + <p>Using WebFinger can provide proof of existence of an associated actor document, as well as make it easier to discover that associated actor document; following this, an actor's inbox can be likewise discovered, and spam or other unwanted messages can be delivered to that actor's inbox. It may be desirable for some systems to not publicly expose an actor's existence and instead rely on the user manually entering their actor's HTTPS URI, or maintaining a "contact list" of bookmarked actors or resources. For such systems, the use of WebFinger is not advisable.</p> 285 + <p>WebFinger allows for the lookup request to redirect; this primarily allows a web host or origin to defer or delegate their WebFinger lookups to a separate WebFinger service, but it can also create an issue when there are multiple redirects. For this reason, anyone making a WebFinger request should take care to limit the maximum number of redirects that they follow.</p> 286 + </section> 287 + <section class="informative" id="future"> 288 + <h2>Future Enhancements</h2> 289 + <p>The current use of WebFinger with ActivityPub could be improved in several ways:</p> 290 + <ul> 291 + <li>Back-linking an actor to a WebFinger address could be more explicit. Current use of <code>preferredUsername</code> is not ideal for constructing WebFinger addresses, and it also does not allow for expressing actual "preferred usernames". An explicit property for denoting the user's "canonical" WebFinger address would ease reverse-discovery concerns.</li> 292 + <li>Implementers could ease their de facto WebFinger requirements by treating WebFinger addresses purely as aliases for the actor's HTTPS identifier, rather than as the canonical identifier. This would make WebFinger address mappings mutable and no longer unary.</li> 293 + <li>WebFinger could be used to expose certain properties or qualified links more directly, skipping the resolution of the actor resource.</li> 294 + <li>An ActivityStreams profile of WebFinger can be defined in order to redirect or directly resolve the actor document if the <code>Accept</code> header specifies the correct media type, skipping the resolution of the JRD.</li> 295 + <li>A convention could be adopted for identifying users by a DNS name instead of an acct: URI.</li> 296 + </ul> 297 + </section> 298 + <section id="conformance"> 299 + </section> 300 + </body> 301 + 302 + </html>