Skip to main content

example_oauth_cli/
main.rs

1#![recursion_limit = "256"]
2
3// Copyright 2023 Kévin Commaille.
4//
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9//     http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16
17use 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/// A command-line tool to demonstrate the steps requiring an interaction with
50/// an OAuth 2.0 authorization server for a Matrix client, using the
51/// Authorization Code flow.
52///
53/// You can test this against any homeserver supporting next-gen auth, like
54/// `matrix.org`.
55///
56/// To use this, just run `cargo run -p example-oauth-cli`, and everything
57/// is interactive after that. You might want to set the `RUST_LOG` environment
58/// variable to `warn` to reduce the noise in the logs. The program exits
59/// whenever an unexpected error occurs.
60///
61/// To reset the login, simply use the `logout` command or delete the folder
62/// containing the session file, the location is shown in the logs. Note that
63/// the database must be deleted too as it can't be reused.
64#[tokio::main]
65async fn main() -> anyhow::Result<()> {
66    tracing_subscriber::fmt::init();
67
68    // The folder containing this example's data.
69    let data_dir =
70        dirs::data_dir().expect("no data_dir directory found").join("matrix_sdk/oauth_cli");
71    // The file where the session is persisted.
72    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
83/// The available commands once the client is logged in.
84fn 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/// The data needed to re-build a client.
98#[derive(Debug, Serialize, Deserialize)]
99struct ClientSession {
100    /// The URL of the homeserver of the user.
101    homeserver: String,
102
103    /// The path of the database.
104    db_path: PathBuf,
105
106    /// The passphrase of the database.
107    passphrase: String,
108}
109
110/// The full session to persist.
111#[derive(Debug, Serialize, Deserialize)]
112struct StoredSession {
113    /// The data to re-build the client.
114    client_session: ClientSession,
115
116    /// The OAuth 2.0 user session.
117    user_session: UserSession,
118
119    /// The OAuth 2.0 client ID.
120    client_id: ClientId,
121}
122
123/// An OAuth 2.0 CLI.
124#[derive(Clone, Debug)]
125struct OAuthCli {
126    /// The Matrix client.
127    client: Client,
128
129    /// Whether this is a restored client.
130    restored: bool,
131
132    /// The path to the file storing the session.
133    session_file: PathBuf,
134}
135
136impl OAuthCli {
137    /// Create a new session by logging in.
138    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                // This would require to register with the authorization server
150                // manually, which we don't support here.
151                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        // Persist the session to reuse it later.
161        // This is not very secure, for simplicity. If the system provides a way
162        // of storing secrets securely, it should be used instead.
163        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    /// Register the client and log in the user via the OAuth 2.0 Authorization
181    /// Code flow.
182    async fn register_and_login(&self) -> anyhow::Result<()> {
183        let oauth = self.client.oauth();
184
185        // We create a loop here so the user can retry if an error happens.
186        loop {
187            // Here we spawn a server to listen on the loopback interface.
188            // Another option would be to register a custom URI
189            // scheme with the system and handle the redirect when
190            // the custom URI scheme is opened.
191            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    /// Restore a previous session from a file.
222    async fn from_stored_session(session_file: PathBuf) -> anyhow::Result<Self> {
223        println!("Previous session found in '{}'", session_file.to_string_lossy());
224
225        // The session was serialized as JSON in a file.
226        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        // Build the client with the previous settings from the session.
231        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        // Restore the Matrix user session.
242        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    /// Run the main program.
252    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    /// Get information about this session.
372    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    /// Get the account management URL.
385    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    /// Watch incoming messages.
406    async fn watch(&self) -> anyhow::Result<()> {
407        let client = &self.client;
408
409        // If this is a new client, ignore previous messages to not fill the
410        // logs. Note that this might not work as intended, the initial
411        // sync might have failed in a previous session.
412        if !self.restored {
413            client.sync_once(SyncSettings::default()).await.unwrap();
414        }
415
416        // Listen to room messages.
417        let handle = client.add_event_handler(on_room_message);
418
419        // Sync.
420        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    /// This watches for incoming responses using the high-level sliding sync
432    /// helpers (`SyncService`).
433    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            // Only fail after getting 5 errors in a row. When we're in an
445            // always-refail scenario, we move from the Error to the
446            // Running state for a bit until we fail again, so we
447            // need to track both failure state and running state,
448            // hence `num_errors` and `num_running`:
449            // - if we failed and num_running was 1, then this is a failure
450            //   following a failure.
451            // - otherwise, we recovered from the failure and we can plain
452            //   continue.
453
454            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                // Concurrently wait for an update from the sync service OR for
462                // the user to press enter and leave early.
463                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    /// Sets up this client so that it automatically saves the session onto disk
516    /// whenever there are new tokens that have been received.
517    ///
518    /// This should always be set up whenever automatic refresh is happening.
519    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                        // The tokens have been refreshed, persist them to disk.
532                        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    /// Update the session stored on the system.
542    ///
543    /// This should be called everytime the access token (and possibly refresh
544    /// token) has changed.
545    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    /// Refresh the access token.
563    async fn refresh_token(&self) -> anyhow::Result<()> {
564        self.client.oauth().refresh_access_token().await?;
565
566        // The session will automatically be refreshed because of the task
567        // persisting the full session upon refresh in
568        // `setup_background_save`.
569
570        println!("\nToken refreshed successfully");
571
572        Ok(())
573    }
574
575    /// Log out from this session.
576    async fn logout(&self) -> anyhow::Result<()> {
577        // Log out via OAuth 2.0.
578        self.client.logout().await?;
579
580        // Delete the stored session and database.
581        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
591/// Build a new client.
592///
593/// Returns the client and the data required to restore the client.
594async fn build_client(data_dir: &Path) -> anyhow::Result<(Client, ClientSession)> {
595    let db_path = data_dir.join("db");
596
597    // Generate a random passphrase.
598    let mut rng = rng();
599    let passphrase: String =
600        (&mut rng).sample_iter(Alphanumeric).take(32).map(char::from).collect();
601
602    // We create a loop here so the user can retry if an error happens.
603    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            // Try autodiscovery or test the URL.
616            .server_name_or_homeserver_url(homeserver)
617            // Make sure to automatically refresh tokens if needs be.
618            .handle_refresh_tokens()
619            // We use the sqlite store, which is available by default. This is the crucial part to
620            // persist the encryption setup.
621            // Note that other store backends are available and you can even implement your own.
622            .sqlite_store(&db_path, Some(&passphrase))
623            .build()
624            .await
625        {
626            Ok(client) => {
627                // Check if the homeserver advertises OAuth 2.0 server metadata.
628                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                        // The client already initialized the store so we need
647                        // to remove it.
648                        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                    // The client already initialized the store so we need to
659                    // remove it.
660                    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                    // Forward other errors, it's unlikely we can retry with a
668                    // different outcome.
669                    return Err(error.into());
670                }
671            },
672        }
673    }
674}
675
676/// Generate the OAuth 2.0 client metadata.
677fn client_metadata() -> Raw<ClientMetadata> {
678    // Native clients should be able to register the IPv4 and IPv6 loopback
679    // interfaces and then point to any port when needing a redirect URI. An
680    // alternative is to use a custom URI scheme registered with the OS.
681    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        // The following fields should be displayed in the OAuth 2.0 authorization server's
693        // web UI as part of the process to get the user's consent. It means that these
694        // should contain real data so the user can make sure that they allow the proper
695        // application. We are cheating here because this is an example.
696        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            // This is a native application (in contrast to a web application, that runs in a
701            // browser).
702            ApplicationType::Native,
703            // We are going to use the Authorization Code flow.
704            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
714/// Open the authorization URL and wait for it to be complete.
715///
716/// Returns the code to obtain the access token.
717async 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
724/// Handle room messages.
725async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) {
726    // We only want to log text messages in joined rooms.
727    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            // Let's fallback to the room ID.
737            room.room_id().to_string()
738        }
739    };
740
741    println!("[{room_name}] {}: {}", event.sender, text_content.body)
742}