1#![recursion_limit = "256"]
2
3use std::{
18 io::{self, Write},
19 net::{Ipv4Addr, Ipv6Addr},
20 path::{Path, PathBuf},
21 sync::Arc,
22};
23
24use anyhow::bail;
25use futures_util::StreamExt;
26use matrix_sdk::{
27 Client, ClientBuildError, Result, RoomState,
28 authentication::oauth::{
29 ClientId, OAuthAuthorizationData, OAuthError, OAuthSession, UserSession,
30 error::OAuthClientRegistrationError,
31 registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
32 },
33 config::SyncSettings,
34 encryption::{CrossSigningResetAuthType, recovery::RecoveryState},
35 room::Room,
36 ruma::{
37 api::client::discovery::get_authorization_server_metadata::v1::AccountManagementActionData,
38 events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
39 serde::Raw,
40 },
41 utils::local_server::{LocalServerBuilder, LocalServerRedirectHandle, QueryString},
42};
43use matrix_sdk_ui::sync_service::SyncService;
44use rand::{RngExt, distr::Alphanumeric, rng};
45use serde::{Deserialize, Serialize};
46use tokio::{fs, io::AsyncBufReadExt as _};
47use url::Url;
48
49#[tokio::main]
65async fn main() -> anyhow::Result<()> {
66 tracing_subscriber::fmt::init();
67
68 let data_dir =
70 dirs::data_dir().expect("no data_dir directory found").join("matrix_sdk/oauth_cli");
71 let session_file = data_dir.join("session.json");
73
74 let cli = if session_file.exists() {
75 OAuthCli::from_stored_session(session_file).await?
76 } else {
77 OAuthCli::new(&data_dir, session_file).await?
78 };
79
80 cli.run().await
81}
82
83fn help() {
85 println!("Usage: [command] [args…]\n");
86 println!("Commands:");
87 println!(" whoami Get information about this session");
88 println!(" account Get the URL to manage this account");
89 println!(" watch [sliding?] Watch new incoming messages until an error occurs");
90 println!(" refresh Refresh the access token");
91 println!(" recover Recover the E2EE secrets from secret storage");
92 println!(" logout Log out of this account");
93 println!(" exit Exit this program");
94 println!(" help Show this message\n");
95}
96
97#[derive(Debug, Serialize, Deserialize)]
99struct ClientSession {
100 homeserver: String,
102
103 db_path: PathBuf,
105
106 passphrase: String,
108}
109
110#[derive(Debug, Serialize, Deserialize)]
112struct StoredSession {
113 client_session: ClientSession,
115
116 user_session: UserSession,
118
119 client_id: ClientId,
121}
122
123#[derive(Clone, Debug)]
125struct OAuthCli {
126 client: Client,
128
129 restored: bool,
131
132 session_file: PathBuf,
134}
135
136impl OAuthCli {
137 async fn new(data_dir: &Path, session_file: PathBuf) -> anyhow::Result<Self> {
139 println!("No previous session found, logging in…");
140
141 let (client, client_session) = build_client(data_dir).await?;
142 let cli = Self { client, restored: false, session_file };
143
144 if let Err(error) = cli.register_and_login().await {
145 if let Some(error) = error.downcast_ref::<OAuthError>()
146 && let OAuthError::ClientRegistration(OAuthClientRegistrationError::NotSupported) =
147 error
148 {
149 bail!(
152 "This server doesn't support dynamic registration.\n\
153 Please select another homeserver."
154 );
155 } else {
156 return Err(error);
157 }
158 }
159
160 let full_session =
164 cli.client.oauth().full_session().expect("A logged-in client should have a session");
165
166 let serialized_session = serde_json::to_string(&StoredSession {
167 client_session,
168 user_session: full_session.user,
169 client_id: full_session.client_id,
170 })?;
171 fs::write(&cli.session_file, serialized_session).await?;
172
173 println!("Session persisted in {}", cli.session_file.to_string_lossy());
174
175 cli.setup_background_save();
176
177 Ok(cli)
178 }
179
180 async fn register_and_login(&self) -> anyhow::Result<()> {
183 let oauth = self.client.oauth();
184
185 loop {
187 let (redirect_uri, server_handle) = LocalServerBuilder::new().spawn().await?;
192
193 let OAuthAuthorizationData { url, .. } = oauth
194 .login(redirect_uri, None, Some(client_metadata().into()), None)
195 .build()
196 .await?;
197
198 let Some(query_string) = use_auth_url(&url, server_handle).await else {
199 println!("Error: failed to login: missing query string on the redirect URL");
200 println!("Please try again.\n");
201 continue;
202 };
203
204 match oauth.finish_login(query_string.into()).await {
205 Ok(()) => {
206 let user_id = self.client.user_id().expect("Got a user ID");
207 println!("Logged in as {user_id}");
208 break;
209 }
210 Err(err) => {
211 println!("Error: failed to login: {err}");
212 println!("Please try again.\n");
213 continue;
214 }
215 }
216 }
217
218 Ok(())
219 }
220
221 async fn from_stored_session(session_file: PathBuf) -> anyhow::Result<Self> {
223 println!("Previous session found in '{}'", session_file.to_string_lossy());
224
225 let serialized_session = fs::read_to_string(&session_file).await?;
227 let StoredSession { client_session, user_session, client_id } =
228 serde_json::from_str(&serialized_session)?;
229
230 let client = Client::builder()
232 .homeserver_url(client_session.homeserver)
233 .handle_refresh_tokens()
234 .sqlite_store(client_session.db_path, Some(&client_session.passphrase))
235 .build()
236 .await?;
237
238 println!("Restoring session for {}…", user_session.meta.user_id);
239
240 let session = OAuthSession { client_id, user: user_session };
241 client.restore_session(session).await?;
243
244 let this = Self { client, restored: true, session_file };
245
246 this.setup_background_save();
247
248 Ok(this)
249 }
250
251 async fn run(&self) -> anyhow::Result<()> {
253 help();
254
255 loop {
256 let mut input = String::new();
257
258 print!("\nEnter command: ");
259 io::stdout().flush().expect("Unable to write to stdout");
260
261 io::stdin().read_line(&mut input).expect("Unable to read user input");
262
263 let mut args = input.trim().split_ascii_whitespace();
264 let cmd = args.next();
265
266 match cmd {
267 Some("whoami") => {
268 self.whoami();
269 }
270 Some("account") => {
271 self.account(None).await;
272 }
273 Some("profile") => {
274 self.account(Some(AccountManagementActionData::Profile)).await;
275 }
276 Some("devices") => {
277 self.account(Some(AccountManagementActionData::DevicesList)).await;
278 }
279 Some("watch") => match args.next() {
280 Some(sub) => {
281 if sub == "sliding" {
282 self.watch_sliding_sync().await?;
283 } else {
284 println!("unknown subcommand for watch: available is 'sliding'");
285 }
286 }
287 None => self.watch().await?,
288 },
289 Some("refresh") => {
290 self.refresh_token().await?;
291 }
292 Some("recover") => {
293 self.recover().await?;
294 }
295 Some("reset-cross-signing") => {
296 self.reset_cross_signing().await?;
297 }
298 Some("logout") => {
299 self.logout().await?;
300 break;
301 }
302 Some("exit") => {
303 break;
304 }
305 Some("help") => {
306 help();
307 }
308 Some(cmd) => {
309 println!("Error: unknown command '{cmd}'\n");
310 help();
311 }
312 None => {
313 println!("Error: no command\n");
314 help()
315 }
316 }
317 }
318
319 Ok(())
320 }
321
322 async fn recover(&self) -> anyhow::Result<()> {
323 let recovery = self.client.encryption().recovery();
324
325 println!("Please enter your recovery key:");
326
327 let mut input = String::new();
328 io::stdin().read_line(&mut input).expect("error: unable to read user input");
329
330 let input = input.trim();
331
332 recovery.recover(input).await?;
333
334 match recovery.state() {
335 RecoveryState::Enabled => println!("Successfully recovered all the E2EE secrets."),
336 RecoveryState::Disabled => println!("Error recovering, recovery is disabled."),
337 RecoveryState::Incomplete => println!("Couldn't recover all E2EE secrets."),
338 _ => unreachable!("We should know our recovery state by now"),
339 }
340
341 Ok(())
342 }
343
344 async fn reset_cross_signing(&self) -> Result<()> {
345 let encryption = self.client.encryption();
346
347 if let Some(handle) = encryption.reset_cross_signing().await? {
348 match handle.auth_type() {
349 CrossSigningResetAuthType::Uiaa(_) => {
350 unimplemented!(
351 "This should never happen, this is after all the OAuth 2.0 example."
352 )
353 }
354 CrossSigningResetAuthType::OAuth(oauth) => {
355 println!(
356 "To reset your end-to-end encryption cross-signing identity, \
357 you first need to approve it at {}",
358 oauth.approval_url
359 );
360
361 handle.auth(oauth.as_auth_data()).await?;
362 }
363 }
364 }
365
366 print!("Successfully reset cross-signing");
367
368 Ok(())
369 }
370
371 fn whoami(&self) {
373 let client = &self.client;
374
375 let user_id = client.user_id().expect("A logged in client has a user ID");
376 let device_id = client.device_id().expect("A logged in client has a device ID");
377 let homeserver = client.homeserver();
378
379 println!("\nUser ID: {user_id}");
380 println!("Device ID: {device_id}");
381 println!("Homeserver URL: {homeserver}");
382 }
383
384 async fn account(&self, action: Option<AccountManagementActionData<'_>>) {
386 let Ok(server_metadata) = self.client.oauth().cached_server_metadata().await else {
387 println!("\nCould not retrieve the server metadata");
388 return;
389 };
390
391 let url = if let Some(action) = action {
392 server_metadata.account_management_url_with_action(action)
393 } else {
394 server_metadata.account_management_uri
395 };
396
397 let Some(url) = url else {
398 println!("\nThis homeserver does not provide the URL to manage your account");
399 return;
400 };
401
402 println!("\nTo manage your account, visit: {url}");
403 }
404
405 async fn watch(&self) -> anyhow::Result<()> {
407 let client = &self.client;
408
409 if !self.restored {
413 client.sync_once(SyncSettings::default()).await.unwrap();
414 }
415
416 let handle = client.add_event_handler(on_room_message);
418
419 let mut sync_stream = Box::pin(client.sync_stream(SyncSettings::default()).await);
421 while let Some(res) = sync_stream.next().await {
422 if let Err(err) = res {
423 client.remove_event_handler(handle);
424 return Err(err.into());
425 }
426 }
427
428 Ok(())
429 }
430
431 async fn watch_sliding_sync(&self) -> anyhow::Result<()> {
434 let sync_service = Arc::new(SyncService::builder(self.client.clone()).build().await?);
435
436 sync_service.start().await;
437
438 println!("press enter to exit the sync loop");
439
440 let mut sync_service_state = sync_service.state();
441
442 let sync_service_clone = sync_service.clone();
443 let task = tokio::spawn(async move {
444 let mut num_errors = 0;
455 let mut num_running = 0;
456
457 let mut _unused = String::new();
458 let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
459
460 loop {
461 tokio::select! {
464 res = sync_service_state.next() => {
465 if let Some(state) = res {
466 match state {
467 matrix_sdk_ui::sync_service::State::Idle
468 | matrix_sdk_ui::sync_service::State::Terminated => {
469 num_errors = 0;
470 num_running = 0;
471 }
472
473 matrix_sdk_ui::sync_service::State::Running => {
474 num_running += 1;
475 if num_running > 1 {
476 num_errors = 0;
477 }
478 }
479
480 matrix_sdk_ui::sync_service::State::Error(_) | matrix_sdk_ui::sync_service::State::Offline => {
481 num_errors += 1;
482 num_running = 0;
483
484 if num_errors == 5 {
485 println!("ran into 5 errors in a row, terminating");
486 break;
487 }
488
489 sync_service_clone.start().await;
490 }
491 }
492 println!("New sync service state update: {state:?}");
493 } else {
494 break;
495 }
496 }
497
498 _ = stdin.read_line(&mut _unused) => {
499 println!("Stopping loop because of user request");
500 sync_service.stop().await;
501
502 break;
503 }
504 }
505 }
506 });
507
508 println!("waiting for sync service to stop...");
509 task.await.unwrap();
510
511 println!("done!");
512 Ok(())
513 }
514
515 fn setup_background_save(&self) {
520 let this = self.clone();
521 tokio::spawn(async move {
522 while let Ok(update) = this.client.subscribe_to_session_changes().recv().await {
523 match update {
524 matrix_sdk::SessionChange::UnknownToken(unknown_token) => {
525 println!(
526 "Received an unknown token error; soft logout? {:?}",
527 unknown_token.soft_logout
528 );
529 }
530 matrix_sdk::SessionChange::TokensRefreshed => {
531 if let Err(err) = this.update_stored_session().await {
533 println!("Unable to store a session in the background: {err}");
534 }
535 }
536 }
537 }
538 });
539 }
540
541 async fn update_stored_session(&self) -> anyhow::Result<()> {
546 println!("Updating the stored session...");
547
548 let serialized_session = fs::read_to_string(&self.session_file).await?;
549 let mut session = serde_json::from_str::<StoredSession>(&serialized_session)?;
550
551 let user_session: UserSession =
552 self.client.oauth().user_session().expect("A logged in client has a session");
553 session.user_session = user_session;
554
555 let serialized_session = serde_json::to_string(&session)?;
556 fs::write(&self.session_file, serialized_session).await?;
557
558 println!("Updating the stored session: done!");
559 Ok(())
560 }
561
562 async fn refresh_token(&self) -> anyhow::Result<()> {
564 self.client.oauth().refresh_access_token().await?;
565
566 println!("\nToken refreshed successfully");
571
572 Ok(())
573 }
574
575 async fn logout(&self) -> anyhow::Result<()> {
577 self.client.logout().await?;
579
580 let data_dir = self.session_file.parent().expect("The file has a parent directory");
582 fs::remove_dir_all(data_dir).await?;
583
584 println!("\nLogged out successfully");
585 println!("\nExiting…");
586
587 Ok(())
588 }
589}
590
591async fn build_client(data_dir: &Path) -> anyhow::Result<(Client, ClientSession)> {
595 let db_path = data_dir.join("db");
596
597 let mut rng = rng();
599 let passphrase: String =
600 (&mut rng).sample_iter(Alphanumeric).take(32).map(char::from).collect();
601
602 loop {
604 let mut homeserver = String::new();
605
606 print!("\nHomeserver: ");
607 io::stdout().flush().expect("Unable to write to stdout");
608 io::stdin().read_line(&mut homeserver).expect("Unable to read user input");
609
610 let homeserver = homeserver.trim();
611
612 println!("\nChecking homeserver…");
613
614 match Client::builder()
615 .server_name_or_homeserver_url(homeserver)
617 .handle_refresh_tokens()
619 .sqlite_store(&db_path, Some(&passphrase))
623 .build()
624 .await
625 {
626 Ok(client) => {
627 match client.oauth().server_metadata().await {
629 Ok(server_metadata) => {
630 println!(
631 "Found OAuth 2.0 server metadata with issuer: {}",
632 server_metadata.issuer
633 );
634
635 let homeserver = client.homeserver().to_string();
636 return Ok((client, ClientSession { homeserver, db_path, passphrase }));
637 }
638 Err(error) => {
639 if error.is_not_supported() {
640 println!(
641 "This homeserver doesn't advertise OAuth 2.0 server metadata."
642 );
643 } else {
644 println!("Error fetching the OAuth 2.0 server metadata: {error:?}");
645 }
646 fs::remove_dir_all(data_dir).await?;
649 }
650 }
651 }
652 Err(error) => match &error {
653 ClientBuildError::AutoDiscovery(_)
654 | ClientBuildError::Url(_)
655 | ClientBuildError::Http(_) => {
656 println!("Error checking the homeserver: {error}");
657 println!("Please try again\n");
658 fs::remove_dir_all(data_dir).await?;
661 }
662 ClientBuildError::InvalidServerName => {
663 println!("Error: not a valid server name");
664 println!("Please try again\n");
665 }
666 _ => {
667 return Err(error.into());
670 }
671 },
672 }
673 }
674}
675
676fn client_metadata() -> Raw<ClientMetadata> {
678 let ipv4_localhost_uri = Url::parse(&format!("http://{}/", Ipv4Addr::LOCALHOST))
682 .expect("Couldn't parse IPv4 redirect URI");
683 let ipv6_localhost_uri = Url::parse(&format!("http://[{}]/", Ipv6Addr::LOCALHOST))
684 .expect("Couldn't parse IPv6 redirect URI");
685 let client_uri = Localized::new(
686 Url::parse("https://github.com/matrix-org/matrix-rust-sdk")
687 .expect("Couldn't parse client URI"),
688 None,
689 );
690
691 let metadata = ClientMetadata {
692 client_name: Some(Localized::new("matrix-rust-sdk-oauth-cli".to_owned(), [])),
697 policy_uri: Some(client_uri.clone()),
698 tos_uri: Some(client_uri.clone()),
699 ..ClientMetadata::new(
700 ApplicationType::Native,
703 vec![OAuthGrantType::AuthorizationCode {
705 redirect_uris: vec![ipv4_localhost_uri, ipv6_localhost_uri],
706 }],
707 client_uri,
708 )
709 };
710
711 Raw::new(&metadata).expect("Couldn't serialize client metadata")
712}
713
714async fn use_auth_url(url: &Url, server_handle: LocalServerRedirectHandle) -> Option<QueryString> {
718 println!("\nPlease authenticate yourself at: {url}\n");
719 println!("Then proceed to the authorization.\n");
720
721 server_handle.await
722}
723
724async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) {
726 if room.state() != RoomState::Joined {
728 return;
729 }
730 let MessageType::Text(text_content) = &event.content.msgtype else { return };
731
732 let room_name = match room.display_name().await {
733 Ok(room_name) => room_name.to_string(),
734 Err(error) => {
735 println!("Error getting room display name: {error}");
736 room.room_id().to_string()
738 }
739 };
740
741 println!("[{room_name}] {}: {}", event.sender, text_content.body)
742}