ActivityPub in OCaml using jsont/eio/requests
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Mastodon OAuth authentication support to apub CLI

Implement OAuth 2.0 authentication for Mastodon instances alongside the
existing HTTP signature auth for ActivityPub federation.

New features:
- `apub auth login user@instance.social` - OAuth login with PKCE
- Commands (post, follow, like, boost) use Mastodon REST API when OAuth
credentials are available, falling back to ActivityPub federation

New files:
- apub_mastodon_oauth.ml - OAuth flow (app registration, PKCE, token exchange)
- apub_mastodon_api.ml - Mastodon REST API client (statuses, follows, etc.)

Session type changes:
- key_id and private_key_pem now optional (for OAuth-only sessions)
- New optional fields: oauth_instance, oauth_access_token, oauth_client_id,
oauth_client_secret
- Backwards compatible JSON codec

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+836 -109
+187 -82
bin/apub.ml
··· 272 272 let doc = "Profile to use for credentials (default: current profile)." in 273 273 Arg.(value & opt (some string) None & info ["profile"; "P"] ~docv:"PROFILE" ~doc) 274 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 + 275 281 (* Result type for credential resolution *) 276 282 type credentials = { 277 283 actor_uri : string; 278 - signing : Apubt.Signing.t option; 284 + auth : auth_mode; 285 + session : Apub_auth_session.t option; [@warning "-69"] 279 286 } 280 287 281 288 (* Resolve credentials from CLI args or saved session *) ··· 285 292 | Some kf, Some kid, Some actor -> 286 293 let pem = In_channel.with_open_bin kf In_channel.input_all in 287 294 let signing = Apubt.Signing.from_pem_exn ~key_id:kid ~pem () in 288 - Ok { actor_uri = actor; signing = Some signing } 295 + Ok { actor_uri = actor; auth = Signature_auth signing; session = None } 289 296 | None, None, None -> 290 297 (* Try loading from session *) 291 298 let fs = env#fs in 292 299 (match Apub_auth_session.load fs ~app_name ?profile () with 293 300 | Some session -> 294 - let signing = Apubt.Signing.from_pem_exn 295 - ~key_id:session.key_id 296 - ~pem:session.private_key_pem () in 297 - Ok { actor_uri = session.actor_uri; signing = Some signing } 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 } 298 314 | None -> 299 315 let profile_name = Option.value ~default:(Apub_auth_session.get_current_profile fs ~app_name) profile in 300 - Error (Printf.sprintf "No credentials found (profile: %s). Use 'apub auth setup' first or provide --actor, --key-file, --key-id." profile_name)) 316 + Error (Printf.sprintf "No credentials found (profile: %s). Use 'apub auth setup' or 'apub auth login' first." profile_name)) 301 317 | _, _, Some actor -> 302 318 (* Actor provided but no keys - try loading keys from session *) 303 319 let fs = env#fs in 304 320 (match Apub_auth_session.load fs ~app_name ?profile () with 305 321 | Some session -> 306 - let signing = Apubt.Signing.from_pem_exn 307 - ~key_id:session.key_id 308 - ~pem:session.private_key_pem () in 309 - Ok { actor_uri = actor; signing = Some signing } 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 } 310 329 | None -> 311 330 (* Just use the actor without signing *) 312 - Ok { actor_uri = actor; signing = None }) 331 + Ok { actor_uri = actor; auth = No_auth; session = None }) 313 332 | _ -> 314 333 Error "Incomplete credentials. Provide all of --actor, --key-file, --key-id, or use 'apub auth setup'." 315 334 316 335 (* Helper to create client with resolved credentials *) 317 336 let create_client_with_credentials ~sw ~user_agent ~timeout env creds = 318 - match creds.signing with 319 - | Some signing -> Apubt.create ~sw ~signing ~user_agent ~timeout env 320 - | None -> Apubt.create ~sw ~user_agent ~timeout env 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 321 340 322 341 (* Post command - create a note *) 323 342 module Post_cmd = struct ··· 354 373 `Error (false, msg) 355 374 | Ok creds -> 356 375 Eio.Switch.run @@ fun sw -> 357 - let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 358 - try 359 - let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 360 - let in_reply_to = Option.map Apubt.Proto.Uri.v reply_to in 361 - let _summary = if sensitive then cw_summary else None in 362 - let activity = 363 - if followers_only then 364 - Apubt.Outbox.followers_only_note client ~actor ?in_reply_to ~content () 365 - else 366 - Apubt.Outbox.public_note client ~actor ?in_reply_to ~content () 367 - in 368 - let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 369 - Fmt.pr "Posted: %s@." (Apubt.Proto.Uri.to_string activity_id); 370 - `Ok () 371 - with 372 - | Apubt.E err -> 373 - Fmt.epr "Error: %a@." Apubt.Error.pp err; 374 - `Error (false, Apubt.Error.to_string err) 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) 375 413 376 414 let term = 377 415 Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file ··· 407 445 `Error (false, msg) 408 446 | Ok creds -> 409 447 Eio.Switch.run @@ fun sw -> 410 - let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 411 - try 412 - let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 413 - let target_actor = 414 - if String.contains target '@' && not (String.starts_with ~prefix:"http" target) then 415 - Apubt.Actor.lookup client target 416 - else 417 - Apubt.Actor.fetch client (Apubt.Proto.Uri.v target) 418 - in 419 - let activity = Apubt.Actor.follow client ~actor ~target:target_actor in 420 - let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 421 - Fmt.pr "Sent follow request: %s@." (Apubt.Proto.Uri.to_string activity_id); 422 - Fmt.pr "Target: %s (%s)@." 423 - (Option.value ~default:"" (Apubt.Proto.Actor.preferred_username target_actor)) 424 - (Apubt.Proto.Uri.to_string (Apubt.Proto.Actor.id target_actor)); 425 - `Ok () 426 - with 427 - | Apubt.E err -> 428 - Fmt.epr "Error: %a@." Apubt.Error.pp err; 429 - `Error (false, Apubt.Error.to_string err) 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) 430 490 431 491 let term = 432 492 Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file ··· 437 497 let man = [ 438 498 `S Manpage.s_description; 439 499 `P "Sends a Follow activity to another actor."; 440 - `P "Uses saved credentials from 'apub auth setup', or override with --actor, --key-file, --key-id."; 500 + `P "Uses saved credentials from 'apub auth setup' or 'apub auth login'."; 441 501 `S Manpage.s_examples; 442 502 `Pre " apub follow gargron@mastodon.social"; 443 503 `Pre " apub follow https://mastodon.social/users/Gargron"; ··· 460 520 `Error (false, msg) 461 521 | Ok creds -> 462 522 Eio.Switch.run @@ fun sw -> 463 - let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 464 - try 465 - let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 466 - let activity = Apubt.Outbox.like client ~actor ~object_:(Apubt.Proto.Uri.v object_uri) in 467 - let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 468 - Fmt.pr "Liked: %s@." object_uri; 469 - Fmt.pr "Activity: %s@." (Apubt.Proto.Uri.to_string activity_id); 470 - `Ok () 471 - with 472 - | Apubt.E err -> 473 - Fmt.epr "Error: %a@." Apubt.Error.pp err; 474 - `Error (false, Apubt.Error.to_string err) 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) 475 555 476 556 let term = 477 557 Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file ··· 482 562 let man = [ 483 563 `S Manpage.s_description; 484 564 `P "Sends a Like activity for the specified object (note, article, etc)."; 485 - `P "Uses saved credentials from 'apub auth setup', or override with --actor, --key-file, --key-id."; 565 + `P "Uses saved credentials from 'apub auth setup' or 'apub auth login'."; 486 566 `S Manpage.s_examples; 487 567 `Pre " apub like https://mastodon.social/notes/123"; 488 568 `Pre " apub like --profile work https://example.com/notes/456"; ··· 504 584 `Error (false, msg) 505 585 | Ok creds -> 506 586 Eio.Switch.run @@ fun sw -> 507 - let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 508 - try 509 - let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 510 - let activity = Apubt.Outbox.announce client ~actor ~object_:(Apubt.Proto.Uri.v object_uri) in 511 - let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 512 - Fmt.pr "Boosted: %s@." object_uri; 513 - Fmt.pr "Activity: %s@." (Apubt.Proto.Uri.to_string activity_id); 514 - `Ok () 515 - with 516 - | Apubt.E err -> 517 - Fmt.epr "Error: %a@." Apubt.Error.pp err; 518 - `Error (false, Apubt.Error.to_string err) 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) 519 619 520 620 let term = 521 621 Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file ··· 526 626 let man = [ 527 627 `S Manpage.s_description; 528 628 `P "Sends an Announce activity (boost/reblog) for the specified object."; 529 - `P "Uses saved credentials from 'apub auth setup', or override with --actor, --key-file, --key-id."; 629 + `P "Uses saved credentials from 'apub auth setup' or 'apub auth login'."; 530 630 `S Manpage.s_examples; 531 631 `Pre " apub boost https://mastodon.social/notes/123"; 532 632 `Pre " apub boost --profile work https://example.com/notes/456"; ··· 541 641 `S Manpage.s_description; 542 642 `P "apub is a command-line tool for interacting with ActivityPub servers."; 543 643 `P "Use 'apub <command> --help' for more information on a specific command."; 544 - `P "To configure your identity, use 'apub auth setup <actor-uri> -k <key.pem>'."; 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)"; 545 647 `S Manpage.s_commands; 546 648 `S Manpage.s_examples; 547 - `Pre " # Setup credentials once"; 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"; 548 653 `Pre " apub auth setup https://example.com/users/alice -k ~/.config/apub/key.pem"; 549 654 `Pre ""; 550 655 `Pre " # Then use commands without --actor/--key-file/--key-id";
+1 -1
bin/dune
··· 2 2 (name apub) 3 3 (public_name apub) 4 4 (package apubt) 5 - (libraries apubt apub_auth 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))
+137 -1
lib/auth/apub_auth_cmd.ml
··· 103 103 Cmd.v info 104 104 Term.(const setup' $ actor_uri_arg $ key_file_arg $ key_id_arg $ profile_arg) 105 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 + 106 224 (* Logout command - clear saved session *) 107 225 108 226 let logout_action ~app_name ~profile env = ··· 144 262 | Some session -> 145 263 Fmt.pr "Profile '%s':@." profile; 146 264 Fmt.pr " Actor: %s@." session.actor_uri; 147 - Fmt.pr " Key ID: %s@." session.key_id; 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); 148 283 Fmt.pr " Created: %s@." session.created_at 149 284 150 285 let status_cmd ~app_name () = ··· 243 378 Cmd.group info 244 379 [ 245 380 setup_cmd ~app_name (); 381 + login_cmd ~app_name (); 246 382 logout_cmd ~app_name (); 247 383 status_cmd ~app_name (); 248 384 profile_cmd ~app_name ();
+66 -11
lib/auth/apub_auth_session.ml
··· 5 5 6 6 type t = { 7 7 actor_uri : string; 8 - key_id : string; 9 - private_key_pem : 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; 10 16 created_at : string; 11 17 } 12 18 13 19 let jsont = 14 20 Jsont.Object.map ~kind:"Session" 15 - (fun actor_uri key_id private_key_pem created_at -> 16 - { actor_uri; key_id; private_key_pem; created_at }) 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 }) 17 25 |> Jsont.Object.mem "actor_uri" Jsont.string ~enc:(fun s -> s.actor_uri) 18 - |> Jsont.Object.mem "key_id" Jsont.string ~enc:(fun s -> s.key_id) 19 - |> Jsont.Object.mem "private_key_pem" Jsont.string 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 20 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) 21 37 |> Jsont.Object.mem "created_at" Jsont.string ~enc:(fun s -> s.created_at) 22 38 |> Jsont.Object.finish 23 39 ··· 150 166 with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> () 151 167 152 168 let pp ppf session = 153 - Fmt.pf ppf "@[<v>Actor: %s@,Key ID: %s@,Created: %s@]" session.actor_uri 154 - session.key_id session.created_at 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 155 176 156 - (* Create a session from components *) 177 + (* Create a signature-based session from components *) 157 178 let create ~actor_uri ~key_id ~private_key_pem = 158 179 { 159 180 actor_uri; 160 - key_id; 161 - private_key_pem; 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; 162 200 created_at = Ptime.to_rfc3339 (Ptime_clock.now ()); 163 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 164 219 165 220 (* Extract a profile name from an actor URI *) 166 221 let profile_name_of_actor_uri uri =
+56 -13
lib/auth/apub_auth_session.mli
··· 6 6 (** Session management for ActivityPub CLI with profile support. 7 7 8 8 This module provides session persistence for ActivityPub actors. Sessions 9 - store the actor URI, key ID, and private key for HTTP signature 10 - authentication. Sessions are stored in profile-specific directories under 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 11 14 [~/.config/<app_name>/profiles/<profile>/session.json]. 12 15 13 16 {2 Directory Structure} ··· 28 31 The current profile is used by default when no profile is specified. 29 32 30 33 {[ 31 - (* Setup an actor and save to a profile *) 34 + (* Setup an actor with HTTP signatures *) 32 35 let session = 33 36 Apub_auth_session.create ~actor_uri:"https://example.com/users/alice" 34 37 ~key_id:"https://example.com/users/alice#main-key" ~private_key_pem 35 38 in 36 39 Apub_auth_session.save fs ~app_name:"apub" ~profile:"alice@example.com" 37 - session; 38 - Apub_auth_session.set_current_profile fs ~app_name:"apub" 39 - "alice@example.com" 40 + session 40 41 41 - (* Later, load the current profile's session *) 42 - let session = Apub_auth_session.load fs ~app_name:"apub" () 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 43 49 ]} *) 44 50 45 51 (** {1 Session Type} *) 46 52 47 53 type t = { 48 54 actor_uri : string; 49 - key_id : string; 50 - private_key_pem : 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; 51 63 created_at : string; 52 64 } 53 - (** Saved session data containing actor credentials for HTTP signatures. *) 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 *) 54 69 55 70 val jsont : t Jsont.t 56 71 (** JSON codec for sessions. *) ··· 58 73 (** {1 Session Creation} *) 59 74 60 75 val create : actor_uri:string -> key_id:string -> private_key_pem:string -> t 61 - (** [create ~actor_uri ~key_id ~private_key_pem] creates a new session with the 62 - current timestamp. *) 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). *) 63 106 64 107 val profile_name_of_actor_uri : string -> string 65 108 (** [profile_name_of_actor_uri uri] extracts a profile name from an actor URI.
+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
+15 -1
lib/auth/dune
··· 2 2 (name apub_auth) 3 3 (public_name apubt.auth) 4 4 (wrapped false) 5 - (libraries cmdliner eio eio_main fmt jsont jsont.bytesrw ptime.clock.os uri x509)) 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))