OCaml HTML5 parser/serialiser based on Python's JustHTML

apubt: Add auth module with XDG-based credential storage

Add authentication system for the apub CLI following the atp-auth pattern:

- New apub_auth library (lib/auth/) with session persistence
- Store actor credentials in ~/.config/apub/profiles/<profile>/session.json
- Support multiple profiles for different ActivityPub accounts
- Auto-load credentials for write commands (post, follow, like, boost)

CLI commands:
- apub auth setup <actor-uri> -k <key.pem> Import actor credentials
- apub auth status Show current profile
- apub auth logout Clear saved credentials
- apub auth profile list/switch/current Profile management

Write commands now work without explicit --actor/--key-file/--key-id
when credentials are saved via 'apub auth setup'.

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

+761 -65
+1
ocaml-apubt/apubt.opam
··· 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 ]
+119 -64
ocaml-apubt/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; ··· 255 257 256 258 (* Common signing options for write operations *) 257 259 let key_file = 258 - let doc = "Path to PEM file containing the private key for signing." in 260 + let doc = "Path to PEM file containing the private key for signing (overrides saved session)." in 259 261 Arg.(value & opt (some file) None & info ["key-file"; "k"] ~docv:"FILE" ~doc) 260 262 261 263 let key_id = 262 - let doc = "Key ID for signing (usually your actor's publicKey.id URI)." in 264 + let doc = "Key ID for signing (overrides saved session)." in 263 265 Arg.(value & opt (some string) None & info ["key-id"; "K"] ~docv:"URI" ~doc) 264 266 265 267 let actor_uri = 266 - let doc = "Your actor URI (required for write operations)." in 268 + let doc = "Your actor URI (overrides saved session)." in 267 269 Arg.(value & opt (some string) None & info ["actor"; "a"] ~docv:"URI" ~doc) 268 270 269 - (* Helper to create a signed client *) 270 - let create_signed_client ~sw ~user_agent ~timeout env ~key_file ~key_id = 271 - match key_file, key_id with 272 - | Some kf, Some kid -> 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 + (* Result type for credential resolution *) 276 + type credentials = { 277 + actor_uri : string; 278 + signing : Apubt.Signing.t option; 279 + } 280 + 281 + (* Resolve credentials from CLI args or saved session *) 282 + let resolve_credentials env ~key_file ~key_id ~actor_uri ~profile = 283 + (* If explicit key_file and key_id provided, use those *) 284 + match key_file, key_id, actor_uri with 285 + | Some kf, Some kid, Some actor -> 273 286 let pem = In_channel.with_open_bin kf In_channel.input_all in 274 287 let signing = Apubt.Signing.from_pem_exn ~key_id:kid ~pem () in 275 - Apubt.create ~sw ~signing ~user_agent ~timeout env 276 - | _ -> Apubt.create ~sw ~user_agent ~timeout env 288 + Ok { actor_uri = actor; signing = Some signing } 289 + | None, None, None -> 290 + (* Try loading from session *) 291 + let fs = env#fs in 292 + (match Apub_auth_session.load fs ~app_name ?profile () with 293 + | 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 } 298 + | None -> 299 + 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)) 301 + | _, _, Some actor -> 302 + (* Actor provided but no keys - try loading keys from session *) 303 + let fs = env#fs in 304 + (match Apub_auth_session.load fs ~app_name ?profile () with 305 + | 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 } 310 + | None -> 311 + (* Just use the actor without signing *) 312 + Ok { actor_uri = actor; signing = None }) 313 + | _ -> 314 + Error "Incomplete credentials. Provide all of --actor, --key-file, --key-id, or use 'apub auth setup'." 315 + 316 + (* Helper to create client with resolved credentials *) 317 + 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 277 321 278 322 (* Post command - create a note *) 279 323 module Post_cmd = struct ··· 301 345 let doc = "Content warning / summary text." in 302 346 Arg.(value & opt (some string) None & info ["summary"; "w"] ~docv:"TEXT" ~doc) 303 347 304 - let run () timeout user_agent key_file key_id actor_uri content reply_to 348 + let run () timeout user_agent key_file key_id actor_uri profile content reply_to 305 349 _public followers_only sensitive cw_summary = 306 - match actor_uri with 307 - | None -> 308 - Fmt.epr "Error: --actor is required for posting.@."; 309 - `Error (false, "Missing required option: --actor") 310 - | Some actor_uri_str -> 311 - Eio_main.run @@ fun env -> 350 + Eio_main.run @@ fun env -> 351 + match resolve_credentials env ~key_file ~key_id ~actor_uri ~profile with 352 + | Error msg -> 353 + Fmt.epr "Error: %s@." msg; 354 + `Error (false, msg) 355 + | Ok creds -> 312 356 Eio.Switch.run @@ fun sw -> 313 - let client = create_signed_client ~sw ~user_agent ~timeout env ~key_file ~key_id in 357 + let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 314 358 try 315 - let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v actor_uri_str) in 359 + let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 316 360 let in_reply_to = Option.map Apubt.Proto.Uri.v reply_to in 317 361 let _summary = if sensitive then cw_summary else None in 318 362 let activity = ··· 331 375 332 376 let term = 333 377 Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file 334 - $ key_id $ actor_uri $ content $ reply_to $ public 378 + $ key_id $ actor_uri $ profile_arg $ content $ reply_to $ public 335 379 $ followers_only $ sensitive $ summary)) 336 380 337 381 let cmd = ··· 339 383 let man = [ 340 384 `S Manpage.s_description; 341 385 `P "Creates and posts a new note (status update)."; 342 - `P "Requires --actor and optionally --key-file/--key-id for signing."; 386 + `P "Uses saved credentials from 'apub auth setup', or override with --actor, --key-file, --key-id."; 343 387 `S Manpage.s_examples; 344 - `Pre " apub post --actor https://example.com/users/alice \"Hello world!\""; 345 - `Pre " apub post --actor https://example.com/users/alice --reply-to https://other.com/notes/123 \"Nice post!\""; 346 - `Pre " apub post --followers-only --actor https://example.com/users/alice \"Followers only content\""; 388 + `Pre " apub post \"Hello world!\""; 389 + `Pre " apub post --reply-to https://other.com/notes/123 \"Nice post!\""; 390 + `Pre " apub post --followers-only \"Followers only content\""; 391 + `Pre " apub post --profile work \"Posting from work account\""; 347 392 ] in 348 393 Cmd.v (Cmd.info "post" ~doc ~man) term 349 394 end ··· 354 399 let doc = "Account to follow (user@domain or URI)." in 355 400 Arg.(required & pos 0 (some string) None & info [] ~docv:"ACCOUNT" ~doc) 356 401 357 - let run () timeout user_agent key_file key_id actor_uri target = 358 - match actor_uri with 359 - | None -> 360 - Fmt.epr "Error: --actor is required for following.@."; 361 - `Error (false, "Missing required option: --actor") 362 - | Some actor_uri_str -> 363 - Eio_main.run @@ fun env -> 402 + let run () timeout user_agent key_file key_id actor_uri profile target = 403 + Eio_main.run @@ fun env -> 404 + match resolve_credentials env ~key_file ~key_id ~actor_uri ~profile with 405 + | Error msg -> 406 + Fmt.epr "Error: %s@." msg; 407 + `Error (false, msg) 408 + | Ok creds -> 364 409 Eio.Switch.run @@ fun sw -> 365 - let client = create_signed_client ~sw ~user_agent ~timeout env ~key_file ~key_id in 410 + let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 366 411 try 367 - let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v actor_uri_str) in 412 + let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 368 413 let target_actor = 369 414 if String.contains target '@' && not (String.starts_with ~prefix:"http" target) then 370 415 Apubt.Actor.lookup client target ··· 385 430 386 431 let term = 387 432 Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file 388 - $ key_id $ actor_uri $ target)) 433 + $ key_id $ actor_uri $ profile_arg $ target)) 389 434 390 435 let cmd = 391 436 let doc = "Follow an actor." in 392 437 let man = [ 393 438 `S Manpage.s_description; 394 439 `P "Sends a Follow activity to another actor."; 395 - `P "Requires --actor for your identity. Use --key-file/--key-id for signing."; 440 + `P "Uses saved credentials from 'apub auth setup', or override with --actor, --key-file, --key-id."; 396 441 `S Manpage.s_examples; 397 - `Pre " apub follow --actor https://example.com/users/alice gargron@mastodon.social"; 398 - `Pre " apub follow --actor https://example.com/users/alice https://mastodon.social/users/Gargron"; 442 + `Pre " apub follow gargron@mastodon.social"; 443 + `Pre " apub follow https://mastodon.social/users/Gargron"; 444 + `Pre " apub follow --profile work colleague@example.com"; 399 445 ] in 400 446 Cmd.v (Cmd.info "follow" ~doc ~man) term 401 447 end ··· 406 452 let doc = "URI of the object to like." in 407 453 Arg.(required & pos 0 (some string) None & info [] ~docv:"URI" ~doc) 408 454 409 - let run () timeout user_agent key_file key_id actor_uri object_uri = 410 - match actor_uri with 411 - | None -> 412 - Fmt.epr "Error: --actor is required for liking.@."; 413 - `Error (false, "Missing required option: --actor") 414 - | Some actor_uri_str -> 415 - Eio_main.run @@ fun env -> 455 + let run () timeout user_agent key_file key_id actor_uri profile object_uri = 456 + Eio_main.run @@ fun env -> 457 + match resolve_credentials env ~key_file ~key_id ~actor_uri ~profile with 458 + | Error msg -> 459 + Fmt.epr "Error: %s@." msg; 460 + `Error (false, msg) 461 + | Ok creds -> 416 462 Eio.Switch.run @@ fun sw -> 417 - let client = create_signed_client ~sw ~user_agent ~timeout env ~key_file ~key_id in 463 + let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 418 464 try 419 - let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v actor_uri_str) in 465 + let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 420 466 let activity = Apubt.Outbox.like client ~actor ~object_:(Apubt.Proto.Uri.v object_uri) in 421 467 let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 422 468 Fmt.pr "Liked: %s@." object_uri; ··· 429 475 430 476 let term = 431 477 Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file 432 - $ key_id $ actor_uri $ object_uri)) 478 + $ key_id $ actor_uri $ profile_arg $ object_uri)) 433 479 434 480 let cmd = 435 481 let doc = "Like an object." in 436 482 let man = [ 437 483 `S Manpage.s_description; 438 484 `P "Sends a Like activity for the specified object (note, article, etc)."; 439 - `P "Requires --actor for your identity. Use --key-file/--key-id for signing."; 485 + `P "Uses saved credentials from 'apub auth setup', or override with --actor, --key-file, --key-id."; 440 486 `S Manpage.s_examples; 441 - `Pre " apub like --actor https://example.com/users/alice https://mastodon.social/notes/123"; 487 + `Pre " apub like https://mastodon.social/notes/123"; 488 + `Pre " apub like --profile work https://example.com/notes/456"; 442 489 ] in 443 490 Cmd.v (Cmd.info "like" ~doc ~man) term 444 491 end ··· 449 496 let doc = "URI of the object to boost." in 450 497 Arg.(required & pos 0 (some string) None & info [] ~docv:"URI" ~doc) 451 498 452 - let run () timeout user_agent key_file key_id actor_uri object_uri = 453 - match actor_uri with 454 - | None -> 455 - Fmt.epr "Error: --actor is required for boosting.@."; 456 - `Error (false, "Missing required option: --actor") 457 - | Some actor_uri_str -> 458 - Eio_main.run @@ fun env -> 499 + let run () timeout user_agent key_file key_id actor_uri profile object_uri = 500 + Eio_main.run @@ fun env -> 501 + match resolve_credentials env ~key_file ~key_id ~actor_uri ~profile with 502 + | Error msg -> 503 + Fmt.epr "Error: %s@." msg; 504 + `Error (false, msg) 505 + | Ok creds -> 459 506 Eio.Switch.run @@ fun sw -> 460 - let client = create_signed_client ~sw ~user_agent ~timeout env ~key_file ~key_id in 507 + let client = create_client_with_credentials ~sw ~user_agent ~timeout env creds in 461 508 try 462 - let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v actor_uri_str) in 509 + let actor = Apubt.Actor.fetch client (Apubt.Proto.Uri.v creds.actor_uri) in 463 510 let activity = Apubt.Outbox.announce client ~actor ~object_:(Apubt.Proto.Uri.v object_uri) in 464 511 let activity_id = Option.get (Apubt.Proto.Activity.id activity) in 465 512 Fmt.pr "Boosted: %s@." object_uri; ··· 472 519 473 520 let term = 474 521 Term.(ret (const run $ setup_log_term $ timeout $ user_agent $ key_file 475 - $ key_id $ actor_uri $ object_uri)) 522 + $ key_id $ actor_uri $ profile_arg $ object_uri)) 476 523 477 524 let cmd = 478 525 let doc = "Boost (announce/reblog) an object." in 479 526 let man = [ 480 527 `S Manpage.s_description; 481 528 `P "Sends an Announce activity (boost/reblog) for the specified object."; 482 - `P "Requires --actor for your identity. Use --key-file/--key-id for signing."; 529 + `P "Uses saved credentials from 'apub auth setup', or override with --actor, --key-file, --key-id."; 483 530 `S Manpage.s_examples; 484 - `Pre " apub boost --actor https://example.com/users/alice https://mastodon.social/notes/123"; 531 + `Pre " apub boost https://mastodon.social/notes/123"; 532 + `Pre " apub boost --profile work https://example.com/notes/456"; 485 533 ] in 486 534 Cmd.v (Cmd.info "boost" ~doc ~man) term 487 535 end ··· 493 541 `S Manpage.s_description; 494 542 `P "apub is a command-line tool for interacting with ActivityPub servers."; 495 543 `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>'."; 496 545 `S Manpage.s_commands; 497 546 `S Manpage.s_examples; 547 + `Pre " # Setup credentials once"; 548 + `Pre " apub auth setup https://example.com/users/alice -k ~/.config/apub/key.pem"; 549 + `Pre ""; 550 + `Pre " # Then use commands without --actor/--key-file/--key-id"; 551 + `Pre " apub post \"Hello world!\""; 552 + `Pre " apub follow gargron@mastodon.social"; 553 + `Pre " apub like https://mastodon.social/notes/123"; 554 + `Pre ""; 555 + `Pre " # Read-only commands (no credentials needed)"; 498 556 `Pre " apub webfinger anil@recoil.org"; 499 557 `Pre " apub actor anil@recoil.org"; 500 558 `Pre " apub outbox anil@recoil.org"; 501 - `Pre " apub post --actor https://example.com/users/alice \"Hello world!\""; 502 - `Pre " apub follow --actor https://example.com/users/alice gargron@mastodon.social"; 503 - `Pre " apub like --actor https://example.com/users/alice https://mastodon.social/notes/123"; 504 - `Pre " apub boost --actor https://example.com/users/alice https://mastodon.social/notes/123"; 505 559 ] in 506 560 let info = Cmd.info "apub" ~version:"0.1" ~doc ~man in 507 561 Cmd.group info [ 562 + Apub_auth_cmd.auth_cmd ~app_name (); 508 563 Webfinger_cmd.cmd; 509 564 Actor_cmd.cmd; 510 565 Outbox_cmd.cmd;
+1 -1
ocaml-apubt/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))
+1
ocaml-apubt/dune-project
··· 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)))
+269
ocaml-apubt/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 + (* Logout command - clear saved session *) 107 + 108 + let logout_action ~app_name ~profile env = 109 + let fs = env#fs in 110 + let profile = 111 + match profile with 112 + | Some p -> p 113 + | None -> Apub_auth_session.get_current_profile fs ~app_name 114 + in 115 + match Apub_auth_session.load fs ~app_name ~profile () with 116 + | None -> Fmt.pr "No session found for profile '%s'.@." profile 117 + | Some session -> 118 + Apub_auth_session.clear fs ~app_name ~profile (); 119 + Fmt.pr "Cleared session for %s (profile: %s).@." session.actor_uri profile 120 + 121 + let logout_cmd ~app_name () = 122 + let doc = "Clear saved actor credentials." in 123 + let info = Cmd.info "logout" ~doc in 124 + let logout' profile = 125 + Eio_main.run @@ fun env -> logout_action ~app_name ~profile env 126 + in 127 + Cmd.v info Term.(const logout' $ profile_arg) 128 + 129 + (* Status command *) 130 + 131 + let status_action ~app_name ~profile env = 132 + let fs = env#fs in 133 + let home = Sys.getenv "HOME" in 134 + Fmt.pr "Config directory: %s/.config/%s@." home app_name; 135 + let current = Apub_auth_session.get_current_profile fs ~app_name in 136 + Fmt.pr "Current profile: %s@." current; 137 + let profiles = Apub_auth_session.list_profiles fs ~app_name in 138 + if profiles <> [] then 139 + Fmt.pr "Available profiles: %s@." (String.concat ", " profiles); 140 + Fmt.pr "@."; 141 + let profile = Option.value ~default:current profile in 142 + match Apub_auth_session.load fs ~app_name ~profile () with 143 + | None -> Fmt.pr "Profile '%s': Not configured.@." profile 144 + | Some session -> 145 + Fmt.pr "Profile '%s':@." profile; 146 + Fmt.pr " Actor: %s@." session.actor_uri; 147 + Fmt.pr " Key ID: %s@." session.key_id; 148 + Fmt.pr " Created: %s@." session.created_at 149 + 150 + let status_cmd ~app_name () = 151 + let doc = "Show authentication status." in 152 + let info = Cmd.info "status" ~doc in 153 + let status' profile = 154 + Eio_main.run @@ fun env -> status_action ~app_name ~profile env 155 + in 156 + Cmd.v info Term.(const status' $ profile_arg) 157 + 158 + (* Profile list command *) 159 + 160 + let profile_list_action ~app_name env = 161 + let fs = env#fs in 162 + let current = Apub_auth_session.get_current_profile fs ~app_name in 163 + let profiles = Apub_auth_session.list_profiles fs ~app_name in 164 + if profiles = [] then 165 + Fmt.pr "No profiles found. Use '%s auth setup' to create one.@." app_name 166 + else begin 167 + Fmt.pr "Profiles:@."; 168 + List.iter 169 + (fun p -> 170 + let marker = if p = current then " (current)" else "" in 171 + match Apub_auth_session.load fs ~app_name ~profile:p () with 172 + | Some session -> Fmt.pr " %s%s - %s@." p marker session.actor_uri 173 + | None -> Fmt.pr " %s%s@." p marker) 174 + profiles 175 + end 176 + 177 + let profile_list_cmd ~app_name () = 178 + let doc = "List available profiles." in 179 + let info = Cmd.info "list" ~doc in 180 + let list' () = Eio_main.run @@ fun env -> profile_list_action ~app_name env in 181 + Cmd.v info Term.(const list' $ const ()) 182 + 183 + (* Profile switch command *) 184 + 185 + let profile_name_arg = 186 + let doc = "Profile name to switch to." in 187 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PROFILE" ~doc) 188 + 189 + let profile_switch_action ~app_name ~profile env = 190 + let fs = env#fs in 191 + let profiles = Apub_auth_session.list_profiles fs ~app_name in 192 + if List.mem profile profiles then begin 193 + Apub_auth_session.set_current_profile fs ~app_name profile; 194 + Fmt.pr "Switched to profile: %s@." profile 195 + end 196 + else begin 197 + Fmt.epr "Profile '%s' not found.@." profile; 198 + if profiles <> [] then 199 + Fmt.epr "Available profiles: %s@." (String.concat ", " profiles); 200 + exit 1 201 + end 202 + 203 + let profile_switch_cmd ~app_name () = 204 + let doc = "Switch to a different profile." in 205 + let info = Cmd.info "switch" ~doc in 206 + let switch' profile = 207 + Eio_main.run @@ fun env -> profile_switch_action ~app_name ~profile env 208 + in 209 + Cmd.v info Term.(const switch' $ profile_name_arg) 210 + 211 + (* Profile current command *) 212 + 213 + let profile_current_action ~app_name env = 214 + let fs = env#fs in 215 + let current = Apub_auth_session.get_current_profile fs ~app_name in 216 + Fmt.pr "%s@." current 217 + 218 + let profile_current_cmd ~app_name () = 219 + let doc = "Show current profile name." in 220 + let info = Cmd.info "current" ~doc in 221 + let current' () = 222 + Eio_main.run @@ fun env -> profile_current_action ~app_name env 223 + in 224 + Cmd.v info Term.(const current' $ const ()) 225 + 226 + (* Profile command group *) 227 + 228 + let profile_cmd ~app_name () = 229 + let doc = "Profile management commands." in 230 + let info = Cmd.info "profile" ~doc in 231 + Cmd.group info 232 + [ 233 + profile_list_cmd ~app_name (); 234 + profile_switch_cmd ~app_name (); 235 + profile_current_cmd ~app_name (); 236 + ] 237 + 238 + (* Auth command group *) 239 + 240 + let auth_cmd ~app_name () = 241 + let doc = "Authentication commands." in 242 + let info = Cmd.info "auth" ~doc in 243 + Cmd.group info 244 + [ 245 + setup_cmd ~app_name (); 246 + logout_cmd ~app_name (); 247 + status_cmd ~app_name (); 248 + profile_cmd ~app_name (); 249 + ] 250 + 251 + (* Helper to load session or exit with error *) 252 + 253 + let with_session ~app_name ?profile f env = 254 + let fs = env#fs in 255 + match Apub_auth_session.load fs ~app_name ?profile () with 256 + | None -> 257 + let profile_msg = 258 + match profile with 259 + | Some p -> Printf.sprintf " (profile: %s)" p 260 + | None -> 261 + let current = 262 + Apub_auth_session.get_current_profile fs ~app_name 263 + in 264 + Printf.sprintf " (profile: %s)" current 265 + in 266 + Fmt.epr "Not configured%s. Use '%s auth setup' first.@." profile_msg 267 + app_name; 268 + exit 1 269 + | Some session -> f fs session
+67
ocaml-apubt/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. *)
+172
ocaml-apubt/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 + key_id : string; 9 + private_key_pem : string; 10 + created_at : string; 11 + } 12 + 13 + let jsont = 14 + 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 }) 17 + |> 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 20 + ~enc:(fun s -> s.private_key_pem) 21 + |> Jsont.Object.mem "created_at" Jsont.string ~enc:(fun s -> s.created_at) 22 + |> Jsont.Object.finish 23 + 24 + (* App config stores the current profile *) 25 + type app_config = { current_profile : string } 26 + 27 + let app_config_jsont = 28 + Jsont.Object.map ~kind:"AppConfig" (fun current_profile -> 29 + { current_profile }) 30 + |> Jsont.Object.mem "current_profile" Jsont.string ~enc:(fun c -> 31 + c.current_profile) 32 + |> Jsont.Object.finish 33 + 34 + let default_profile = "default" 35 + 36 + (* Helper to create directory if it doesn't exist *) 37 + let mkdir_if_missing ~perm path = 38 + try Eio.Path.mkdir ~perm path 39 + with Eio.Io (Eio.Fs.E (Eio.Fs.Already_exists _), _) -> () 40 + 41 + (* Base config directory for the app *) 42 + let base_config_dir fs ~app_name = 43 + let home = Sys.getenv "HOME" in 44 + (* Ensure ~/.config exists first *) 45 + let dot_config = Eio.Path.(fs / home / ".config") in 46 + mkdir_if_missing ~perm:0o755 dot_config; 47 + (* Then create the app-specific directory *) 48 + let config_path = Eio.Path.(dot_config / app_name) in 49 + mkdir_if_missing ~perm:0o700 config_path; 50 + config_path 51 + 52 + (* Profiles directory *) 53 + let profiles_dir fs ~app_name = 54 + let base = base_config_dir fs ~app_name in 55 + let profiles = Eio.Path.(base / "profiles") in 56 + mkdir_if_missing ~perm:0o700 profiles; 57 + profiles 58 + 59 + (* Config directory for a specific profile *) 60 + let config_dir fs ~app_name ?profile () = 61 + let profile_name = Option.value ~default:default_profile profile in 62 + let profiles = profiles_dir fs ~app_name in 63 + let profile_dir = Eio.Path.(profiles / profile_name) in 64 + mkdir_if_missing ~perm:0o700 profile_dir; 65 + profile_dir 66 + 67 + (* App config file (stores current profile) *) 68 + let app_config_file fs ~app_name = 69 + Eio.Path.(base_config_dir fs ~app_name / "config.json") 70 + 71 + let load_app_config fs ~app_name = 72 + let path = app_config_file fs ~app_name in 73 + try 74 + Eio.Path.load path 75 + |> Jsont_bytesrw.decode_string app_config_jsont 76 + |> Result.to_option 77 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> None 78 + 79 + let save_app_config fs ~app_name config = 80 + let path = app_config_file fs ~app_name in 81 + match 82 + Jsont_bytesrw.encode_string ~format:Jsont.Indent app_config_jsont config 83 + with 84 + | Ok content -> Eio.Path.save ~create:(`Or_truncate 0o600) path content 85 + | Error e -> failwith ("Failed to encode app config: " ^ e) 86 + 87 + (* Get the current profile name *) 88 + let get_current_profile fs ~app_name = 89 + match load_app_config fs ~app_name with 90 + | Some config -> config.current_profile 91 + | None -> default_profile 92 + 93 + (* Set the current profile *) 94 + let set_current_profile fs ~app_name profile = 95 + save_app_config fs ~app_name { current_profile = profile } 96 + 97 + (* List all available profiles *) 98 + let list_profiles fs ~app_name = 99 + let profiles = profiles_dir fs ~app_name in 100 + try 101 + Eio.Path.read_dir profiles 102 + |> List.filter (fun name -> 103 + (* Check if it's a directory with a session.json *) 104 + let dir = Eio.Path.(profiles / name) in 105 + let session = Eio.Path.(dir / "session.json") in 106 + try 107 + ignore (Eio.Path.load session); 108 + true 109 + with _ -> false) 110 + |> List.sort String.compare 111 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> [] 112 + 113 + (* Session file within a profile directory *) 114 + let session_file fs ~app_name ?profile () = 115 + Eio.Path.(config_dir fs ~app_name ?profile () / "session.json") 116 + 117 + let load fs ~app_name ?profile () = 118 + let profile = 119 + match profile with 120 + | Some p -> Some p 121 + | None -> 122 + (* Use current profile if none specified *) 123 + let current = get_current_profile fs ~app_name in 124 + Some current 125 + in 126 + let path = session_file fs ~app_name ?profile () in 127 + try 128 + Eio.Path.load path |> Jsont_bytesrw.decode_string jsont |> Result.to_option 129 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> None 130 + 131 + let save fs ~app_name ?profile session = 132 + let profile = 133 + match profile with 134 + | Some p -> Some p 135 + | None -> Some (get_current_profile fs ~app_name) 136 + in 137 + let path = session_file fs ~app_name ?profile () in 138 + match Jsont_bytesrw.encode_string ~format:Jsont.Indent jsont session with 139 + | Ok content -> Eio.Path.save ~create:(`Or_truncate 0o600) path content 140 + | Error e -> failwith ("Failed to encode session: " ^ e) 141 + 142 + let clear fs ~app_name ?profile () = 143 + let profile = 144 + match profile with 145 + | Some p -> Some p 146 + | None -> Some (get_current_profile fs ~app_name) 147 + in 148 + let path = session_file fs ~app_name ?profile () in 149 + try Eio.Path.unlink path 150 + with Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) -> () 151 + 152 + 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 155 + 156 + (* Create a session from components *) 157 + let create ~actor_uri ~key_id ~private_key_pem = 158 + { 159 + actor_uri; 160 + key_id; 161 + private_key_pem; 162 + created_at = Ptime.to_rfc3339 (Ptime_clock.now ()); 163 + } 164 + 165 + (* Extract a profile name from an actor URI *) 166 + let profile_name_of_actor_uri uri = 167 + (* Convert https://example.com/users/alice to alice@example.com *) 168 + match Uri.of_string uri |> fun u -> (Uri.host u, Uri.path u) with 169 + | Some host, path -> 170 + let name = Filename.basename path in 171 + if name = "" || name = "/" then host else name ^ "@" ^ host 172 + | None, _ -> "default"
+126
ocaml-apubt/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 + store the actor URI, key ID, and private key for HTTP signature 10 + authentication. Sessions are stored in profile-specific directories under 11 + [~/.config/<app_name>/profiles/<profile>/session.json]. 12 + 13 + {2 Directory Structure} 14 + 15 + {v 16 + ~/.config/<app_name>/ 17 + config.json # Stores current_profile setting 18 + profiles/ 19 + default/ 20 + session.json # Session for "default" profile 21 + alice@mastodon.social/ 22 + session.json # Session for "alice@mastodon.social" profile 23 + v} 24 + 25 + {2 Profile Usage} 26 + 27 + Profiles allow multiple ActivityPub actors to be configured simultaneously. 28 + The current profile is used by default when no profile is specified. 29 + 30 + {[ 31 + (* Setup an actor and save to a profile *) 32 + let session = 33 + Apub_auth_session.create ~actor_uri:"https://example.com/users/alice" 34 + ~key_id:"https://example.com/users/alice#main-key" ~private_key_pem 35 + in 36 + 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 + 41 + (* Later, load the current profile's session *) 42 + let session = Apub_auth_session.load fs ~app_name:"apub" () 43 + ]} *) 44 + 45 + (** {1 Session Type} *) 46 + 47 + type t = { 48 + actor_uri : string; 49 + key_id : string; 50 + private_key_pem : string; 51 + created_at : string; 52 + } 53 + (** Saved session data containing actor credentials for HTTP signatures. *) 54 + 55 + val jsont : t Jsont.t 56 + (** JSON codec for sessions. *) 57 + 58 + (** {1 Session Creation} *) 59 + 60 + 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. *) 63 + 64 + val profile_name_of_actor_uri : string -> string 65 + (** [profile_name_of_actor_uri uri] extracts a profile name from an actor URI. 66 + For example, [https://example.com/users/alice] becomes [alice@example.com]. *) 67 + 68 + (** {1 Profile Management} *) 69 + 70 + val default_profile : string 71 + (** The default profile name (["default"]). *) 72 + 73 + val get_current_profile : Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> string 74 + (** [get_current_profile fs ~app_name] returns the current profile name. Returns 75 + {!default_profile} if no profile has been set. *) 76 + 77 + val set_current_profile : 78 + Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> string -> unit 79 + (** [set_current_profile fs ~app_name profile] sets the current profile. *) 80 + 81 + val list_profiles : Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> string list 82 + (** [list_profiles fs ~app_name] returns all profiles that have sessions. 83 + Returns profile names sorted alphabetically. *) 84 + 85 + (** {1 Directory Paths} *) 86 + 87 + val base_config_dir : 88 + Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> Eio.Fs.dir_ty Eio.Path.t 89 + (** [base_config_dir fs ~app_name] returns the base config directory for the app 90 + ([~/.config/<app_name>]), creating it if needed. *) 91 + 92 + val config_dir : 93 + Eio.Fs.dir_ty Eio.Path.t -> 94 + app_name:string -> 95 + ?profile:string -> 96 + unit -> 97 + Eio.Fs.dir_ty Eio.Path.t 98 + (** [config_dir fs ~app_name ?profile ()] returns the config directory for a 99 + profile, creating it if needed. 100 + @param profile Profile name (default: current profile) *) 101 + 102 + (** {1 Session Persistence} *) 103 + 104 + val save : 105 + Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> ?profile:string -> t -> unit 106 + (** [save fs ~app_name ?profile session] saves the session. 107 + @param profile Profile name (default: current profile) *) 108 + 109 + val load : 110 + Eio.Fs.dir_ty Eio.Path.t -> 111 + app_name:string -> 112 + ?profile:string -> 113 + unit -> 114 + t option 115 + (** [load fs ~app_name ?profile ()] loads a saved session. 116 + @param profile Profile name (default: current profile) *) 117 + 118 + val clear : 119 + Eio.Fs.dir_ty Eio.Path.t -> app_name:string -> ?profile:string -> unit -> unit 120 + (** [clear fs ~app_name ?profile ()] removes the saved session. 121 + @param profile Profile name (default: current profile) *) 122 + 123 + (** {1 Session Utilities} *) 124 + 125 + val pp : t Fmt.t 126 + (** Pretty-print a session (does not print the private key). *)
+5
ocaml-apubt/lib/auth/dune
··· 1 + (library 2 + (name apub_auth) 3 + (public_name apubt.auth) 4 + (wrapped false) 5 + (libraries cmdliner eio eio_main fmt jsont jsont.bytesrw ptime.clock.os uri x509))