1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
use std::{
    env, fmt,
    io::{self, Write},
    process::exit,
};

use anyhow::anyhow;
use matrix_sdk::{
    config::SyncSettings,
    ruma::{
        api::client::session::get_login_types::v3::{IdentityProvider, LoginType},
        events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
    },
    Client, Room, RoomState,
};
use url::Url;

/// The initial device name when logging in with a device for the first time.
const INITIAL_DEVICE_DISPLAY_NAME: &str = "login client";

/// A simple program that adapts to the different login methods offered by a
/// Matrix homeserver.
///
/// Homeservers usually offer to login either via password, Single Sign-On (SSO)
/// or both.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();

    let Some(homeserver_url) = env::args().nth(1) else {
        eprintln!("Usage: {} <homeserver_url>", env::args().next().unwrap());
        exit(1)
    };

    login_and_sync(homeserver_url).await?;

    Ok(())
}

/// Log in to the given homeserver and sync.
async fn login_and_sync(homeserver_url: String) -> anyhow::Result<()> {
    let homeserver_url = Url::parse(&homeserver_url)?;
    let client = Client::new(homeserver_url).await?;

    // First, let's figure out what login types are supported by the homeserver.
    let mut choices = Vec::new();
    let login_types = client.matrix_auth().get_login_types().await?.flows;

    for login_type in login_types {
        match login_type {
            LoginType::Password(_) => {
                choices.push(LoginChoice::Password)
            }
            LoginType::Sso(sso) => {
                if sso.identity_providers.is_empty() {
                    choices.push(LoginChoice::Sso)
                } else {
                    choices.extend(sso.identity_providers.into_iter().map(LoginChoice::SsoIdp))
                }
            }
            // This is used for SSO, so it's not a separate choice.
            LoginType::Token(_) |
            // This is only for application services, ignore it here.
            LoginType::ApplicationService(_) => {},
            // We don't support unknown login types.
            _ => {},
        }
    }

    match choices.len() {
        0 => return Err(anyhow!("Homeserver login types incompatible with this client")),
        1 => choices[0].login(&client).await?,
        _ => offer_choices_and_login(&client, choices).await?,
    }

    // Now that we are logged in, we can sync and listen to new messages.
    client.add_event_handler(on_room_message);
    // This will sync until an error happens or the program is killed.
    client.sync(SyncSettings::new()).await?;

    Ok(())
}

#[derive(Debug)]
enum LoginChoice {
    /// Login with username and password.
    Password,

    /// Login with SSO.
    Sso,

    /// Login with a specific SSO identity provider.
    SsoIdp(IdentityProvider),
}

impl LoginChoice {
    /// Login with this login choice.
    async fn login(&self, client: &Client) -> anyhow::Result<()> {
        match self {
            LoginChoice::Password => login_with_password(client).await,
            LoginChoice::Sso => login_with_sso(client, None).await,
            LoginChoice::SsoIdp(idp) => login_with_sso(client, Some(idp)).await,
        }
    }
}

impl fmt::Display for LoginChoice {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            LoginChoice::Password => write!(f, "Username and password"),
            LoginChoice::Sso => write!(f, "SSO"),
            LoginChoice::SsoIdp(idp) => write!(f, "SSO via {}", idp.name),
        }
    }
}

/// Offer the given choices to the user and login with the selected option.
async fn offer_choices_and_login(client: &Client, choices: Vec<LoginChoice>) -> anyhow::Result<()> {
    println!("Several options are available to login with this homeserver:\n");

    let choice = loop {
        for (idx, login_choice) in choices.iter().enumerate() {
            println!("{idx}) {login_choice}");
        }

        print!("\nEnter your choice: ");
        io::stdout().flush().expect("Unable to write to stdout");
        let mut choice_str = String::new();
        io::stdin().read_line(&mut choice_str).expect("Unable to read user input");

        match choice_str.trim().parse::<usize>() {
            Ok(choice) => {
                if choice >= choices.len() {
                    eprintln!("This is not a valid choice");
                } else {
                    break choice;
                }
            }
            Err(_) => eprintln!("This is not a valid choice. Try again.\n"),
        };
    };

    choices[choice].login(client).await?;

    Ok(())
}

/// Login with a username and password.
async fn login_with_password(client: &Client) -> anyhow::Result<()> {
    println!("Logging in with username and password…");

    loop {
        print!("\nUsername: ");
        io::stdout().flush().expect("Unable to write to stdout");
        let mut username = String::new();
        io::stdin().read_line(&mut username).expect("Unable to read user input");
        username = username.trim().to_owned();

        print!("Password: ");
        io::stdout().flush().expect("Unable to write to stdout");
        let mut password = String::new();
        io::stdin().read_line(&mut password).expect("Unable to read user input");
        password = password.trim().to_owned();

        match client
            .matrix_auth()
            .login_username(&username, &password)
            .initial_device_display_name(INITIAL_DEVICE_DISPLAY_NAME)
            .await
        {
            Ok(_) => {
                println!("Logged in as {username}");
                break;
            }
            Err(error) => {
                println!("Error logging in: {error}");
                println!("Please try again\n");
            }
        }
    }

    Ok(())
}

/// Login with SSO.
async fn login_with_sso(client: &Client, idp: Option<&IdentityProvider>) -> anyhow::Result<()> {
    println!("Logging in with SSO…");

    let mut login_builder = client.matrix_auth().login_sso(|url| async move {
        // Usually we would want to use a library to open the URL in the browser, but
        // let's keep it simple.
        println!("\nOpen this URL in your browser: {url}\n");
        println!("Waiting for login token…");
        Ok(())
    });

    if let Some(idp) = idp {
        login_builder = login_builder.identity_provider_id(&idp.id);
    }

    login_builder.await?;

    println!("Logged in as {}", client.user_id().unwrap());

    Ok(())
}

/// Handle room messages by logging them.
async fn on_room_message(event: OriginalSyncRoomMessageEvent, room: Room) {
    // We only want to listen to joined rooms.
    if room.state() != RoomState::Joined {
        return;
    }

    // We only want to log text messages.
    let MessageType::Text(msgtype) = &event.content.msgtype else {
        return;
    };

    let member = room
        .get_member(&event.sender)
        .await
        .expect("Couldn't get the room member")
        .expect("The room member doesn't exist");
    let name = member.name();

    println!("{name}: {}", msgtype.body);
}