chiark / gitweb /
Implement the login procedure, without saving.
authorSimon Tatham <anakin@pobox.com>
Wed, 3 Jan 2024 08:19:37 +0000 (08:19 +0000)
committerSimon Tatham <anakin@pobox.com>
Wed, 3 Jan 2024 10:16:57 +0000 (10:16 +0000)
Currently, the JSON-serialised AuthConfig is written to stdout, just
to prove during development that it works. Next step is to save it to
where it ought to live.

src/client.rs
src/lib.rs
src/login.rs [new file with mode: 0644]
src/main.rs
src/types.rs

index 44c9070be2ac67e8fef8a7b58a3c77ae1ee00583..4b7d4deca010cd47b10dcbfe0d8a1cf14605cb7b 100644 (file)
@@ -100,14 +100,14 @@ impl std::fmt::Display for ClientError {
 
 // Our own struct to collect the pieces of an HTTP request before we
 // pass it on to reqwests. Allows incremental adding of request parameters.
-struct Req {
+pub struct Req {
     method: reqwest::Method,
     url_suffix: String,
     parameters: Vec<(String, String)>,
 }
 
 impl Req {
-    fn get(url_suffix: &str) -> Self {
+    pub fn get(url_suffix: &str) -> Self {
         Req {
             method: reqwest::Method::GET,
             url_suffix: url_suffix.to_owned(),
@@ -115,7 +115,7 @@ impl Req {
         }
     }
 
-    fn post(url_suffix: &str) -> Self {
+    pub fn post(url_suffix: &str) -> Self {
         Req {
             method: reqwest::Method::POST,
             url_suffix: url_suffix.to_owned(),
@@ -123,15 +123,43 @@ impl Req {
         }
     }
 
-    fn param<T>(mut self, key: &str, value: T) -> Self
+    pub fn param<T>(mut self, key: &str, value: T) -> Self
         where T: ReqParam
     {
         self.parameters.push((key.to_owned(), value.param_value()));
         self
     }
+
+    pub fn url(&self, base_url: &str) -> Result<(String, Url), ClientError> {
+        let urlstr = base_url.to_owned() + &self.url_suffix;
+        let parsed = if self.parameters.is_empty() {
+            Url::parse(&urlstr)
+        } else {
+            Url::parse_with_params(&urlstr, self.parameters.iter())
+        };
+        let url = match parsed {
+            Ok(url) => Ok(url),
+            Err(e) => Err(ClientError::UrlParseError(
+               urlstr.clone(), e.to_string())),
+        }?;
+        Ok((urlstr, url))
+    }
+
+    pub fn build(self, base_url: &str, client: &reqwest::blocking::Client,
+                 bearer_token: Option<&str>) ->
+        Result<(String, reqwest::blocking::RequestBuilder), ClientError>
+    {
+        let (urlstr, url) = self.url(base_url)?;
+        let req = client.request(self.method, url);
+        let req = match bearer_token {
+            Some(tok) => req.bearer_auth(tok),
+            None => req,
+        };
+        Ok((urlstr, req))
+    }
 }
 
-trait ReqParam {
+pub trait ReqParam {
     fn param_value(self) -> String;
 }
 
@@ -168,11 +196,56 @@ pub enum FeedExtend {
     Initial, Past, Future
 }
 
+pub fn reqwest_client() -> Result<reqwest::blocking::Client, ClientError> {
+    // We turn off cross-site HTTP redirections in our client
+    // objects. That's because in general it doesn't do any good
+    // for a Mastodon API endpoint to redirect to another host:
+    // most requests have to be authenticated, and if you're
+    // redirected to another host, that host (by normal reqwest
+    // policy for cross-site redirections) won't receive your auth
+    // header. So we might as well reject the redirection in the
+    // first place.
+    //
+    // An exception is when setting up streaming connections,
+    // because those _can_ redirect to another host - but in that
+    // case, the target host can be known in advance (via the
+    // streaming URL in the instance configuration), and also, we
+    // still can't let _reqwest_ quietly follow the redirection,
+    // because it will strip off the auth. So we follow it
+    // ourselves manually, vet the target URL, and put the auth
+    // back on.
+    let no_xsite = reqwest::redirect::Policy::custom(|attempt| {
+        if attempt.previous().len() > 10 {
+            attempt.error("too many redirects")
+        } else if let Some(prev_url) = attempt.previous().last() {
+            let next_url = attempt.url();
+            if (prev_url.host(), prev_url.port()) !=
+                (next_url.host(), next_url.port())
+            {
+                // Stop and pass the 3xx response back to the
+                // caller, rather than throwing a fatal error.
+                // That way, the streaming setup can implement its
+                // special case.
+                attempt.stop()
+            } else {
+                attempt.follow()
+            }
+        } else {
+            panic!("confusing redirect with no previous URLs!");
+        }
+    });
+
+    let client = reqwest::blocking::Client::builder()
+        .redirect(no_xsite)
+        .build()?;
+    Ok(client)
+}
+
 impl Client {
     pub fn new(cfgloc: &ConfigLocation) -> Result<Self, ClientError> {
         Ok(Client {
             auth: AuthConfig::load(cfgloc)?,
-            client: Self::build_client()?,
+            client: reqwest_client()?,
             accounts: HashMap::new(),
             statuses: HashMap::new(),
             notifications: HashMap::new(),
@@ -182,52 +255,6 @@ impl Client {
         })
     }
 
-    fn build_client() -> Result<reqwest::blocking::Client, ClientError>
-    {
-        // We turn off cross-site HTTP redirections in our client
-        // objects. That's because in general it doesn't do any good
-        // for a Mastodon API endpoint to redirect to another host:
-        // most requests have to be authenticated, and if you're
-        // redirected to another host, that host (by normal reqwest
-        // policy for cross-site redirections) won't receive your auth
-        // header. So we might as well reject the redirection in the
-        // first place.
-        //
-        // An exception is when setting up streaming connections,
-        // because those _can_ redirect to another host - but in that
-        // case, the target host can be known in advance (via the
-        // streaming URL in the instance configuration), and also, we
-        // still can't let _reqwest_ quietly follow the redirection,
-        // because it will strip off the auth. So we follow it
-        // ourselves manually, vet the target URL, and put the auth
-        // back on.
-        let no_xsite = reqwest::redirect::Policy::custom(|attempt| {
-            if attempt.previous().len() > 10 {
-                attempt.error("too many redirects")
-            } else if let Some(prev_url) = attempt.previous().last() {
-                let next_url = attempt.url();
-                if (prev_url.host(), prev_url.port()) !=
-                    (next_url.host(), next_url.port())
-                {
-                    // Stop and pass the 3xx response back to the
-                    // caller, rather than throwing a fatal error.
-                    // That way, the streaming setup can implement its
-                    // special case.
-                    attempt.stop()
-                } else {
-                    attempt.follow()
-                }
-            } else {
-                panic!("confusing redirect with no previous URLs!");
-            }
-        });
-
-        let client = reqwest::blocking::Client::builder()
-            .redirect(no_xsite)
-            .build()?;
-        Ok(client)
-    }
-
     pub fn set_writable(&mut self, permit: bool) {
         self.permit_write = permit;
     }
@@ -251,21 +278,8 @@ impl Client {
                 "Non-GET request attempted in readonly mode".to_string()));
         }
 
-        let urlstr = self.auth.instance_url.clone() + "/api/" +
-            &req.url_suffix;
-        let parsed = if req.parameters.is_empty() {
-            Url::parse(&urlstr)
-        } else {
-            Url::parse_with_params(&urlstr, req.parameters.iter())
-        };
-        let url = match parsed {
-            Ok(url) => Ok(url),
-            Err(e) => Err(ClientError::UrlParseError(
-               urlstr.clone(), e.to_string())),
-        }?;
-
-        Ok((urlstr, client.request(req.method, url)
-            .bearer_auth(&self.auth.user_token)))
+        let base_url = self.auth.instance_url.to_owned() + "/api/";
+        req.build(&base_url, client, Some(&self.auth.user_token))
     }
 
     fn api_request(&self, req: Req) ->
@@ -619,7 +633,7 @@ impl Client {
         };
         let method = req.method.clone(); // to reuse for redirects below
 
-        let client = Self::build_client()?;
+        let client = reqwest_client()?;
         let (url, mut req) = self.api_request_cl(&client, req)?;
         let mut rsp = req.send()?;
         if rsp.status().is_redirection() {
index fc8379daa97a0d5c7eabf94298fe887e3cd16b6e..5cf1fa8db099481d4101c9e2fbcbeece1fff07fe 100644 (file)
@@ -1,4 +1,5 @@
 pub mod types;
+pub mod login;
 pub mod auth;
 pub mod config;
 pub mod html;
diff --git a/src/login.rs b/src/login.rs
new file mode 100644 (file)
index 0000000..66c730e
--- /dev/null
@@ -0,0 +1,190 @@
+use reqwest::Url;
+use std::io::Write;
+
+use super::auth::AuthConfig;
+use super::client::{reqwest_client, Req, ClientError};
+use super::types::{Account, Application, Instance, Token};
+
+struct Login {
+    instance_url: String,
+    client: reqwest::blocking::Client,
+}
+
+enum AppTokenType<'a> {
+    ClientCredentials,
+    AuthorizationCode(&'a str),
+}
+use AppTokenType::*;
+
+const REDIRECT_MAGIC_STRING: &'static str = "urn:ietf:wg:oauth:2.0:oob";
+
+impl Login {
+    fn new(instance_url: &str) -> Result<Self, ClientError> {
+        Ok(Login {
+            instance_url: instance_url.to_owned(),
+            client: reqwest_client()?,
+        })
+    }
+
+    fn register_client(&self) -> Result<Application, ClientError> {
+        let (url, req) = Req::post("/api/v1/apps")
+            .param("redirect_uris", REDIRECT_MAGIC_STRING)
+            .param("client_name", "Mastodonochrome")
+            .param("scopes", "read write push")
+            .param("website", "https://www.chiark.greenend.org.uk/~sgtatham/mastodonochrome/")
+            .build(&self.instance_url, &self.client, None)?;
+        let rsp = req.send()?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url.clone(), rspstatus.to_string()))
+        } else {
+            let app: Application = match serde_json::from_str(&rsp.text()?) {
+                Ok(app) => Ok(app),
+                Err(e) => Err(ClientError::UrlError(
+                    url.clone(), e.to_string())),
+            }?;
+            Ok(app)
+        }
+    }
+
+    fn get_token(&self, app: &Application, toktype: AppTokenType) ->
+        Result<Token, ClientError>
+    {
+        let client_id = match &app.client_id {
+            Some(id) => Ok(id),
+            None => Err(ClientError::InternalError("registering application did not return a client id".to_owned())),
+        }?;
+        let client_secret = match &app.client_secret {
+            Some(id) => Ok(id),
+            None => Err(ClientError::InternalError("registering application did not return a client secret".to_owned())),
+        }?;
+
+        let req = Req::post("/oauth/token")
+            .param("redirect_uri", REDIRECT_MAGIC_STRING)
+            .param("client_id", client_id)
+            .param("client_secret", client_secret);
+        let req = match toktype {
+            ClientCredentials => req
+                .param("grant_type", "client_credentials"),
+            AuthorizationCode(code) => req
+                .param("grant_type", "authorization_code")
+                .param("code", code)
+                .param("scope", "read write push"),
+        };
+        let (url, req) = req.build(&self.instance_url, &self.client, None)?;
+        let rsp = req.send()?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url.clone(), rspstatus.to_string()))
+        } else {
+            let tok: Token = match serde_json::from_str(&rsp.text()?) {
+                Ok(tok) => Ok(tok),
+                Err(e) => Err(ClientError::UrlError(
+                    url.clone(), e.to_string())),
+            }?;
+            Ok(tok)
+        }
+    }
+
+    fn get<T: for<'a> serde::Deserialize<'a>>(&self, path: &str, token: &str) ->
+        Result<T, ClientError>
+    {
+        let (url, req) = Req::get(path)
+            .build(&self.instance_url, &self.client, Some(token))?;
+        let rsp = req.send()?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url.clone(), rspstatus.to_string()))
+        } else {
+            let tok: T = match serde_json::from_str(&rsp.text()?) {
+                Ok(tok) => Ok(tok),
+                Err(e) => Err(ClientError::UrlError(
+                    url.clone(), e.to_string())),
+            }?;
+            Ok(tok)
+        }
+    }
+
+    fn get_auth_url(&self, app: &Application) -> Result<String, ClientError> {
+        let client_id = match &app.client_id {
+            Some(id) => Ok(id),
+            None => Err(ClientError::InternalError("registering application did not return a client id".to_owned())),
+        }?;
+
+        let (_urlstr, url) = Req::get("/oauth/authorize")
+            .param("redirect_uri", REDIRECT_MAGIC_STRING)
+            .param("client_id", client_id)
+            .param("scope", "read write push")
+            .param("response_type", "code")
+            .url(&self.instance_url)?;
+        Ok(url.to_string())
+    }
+}
+
+pub fn login(instance_url: &str) -> Result<(), ClientError> {
+    // Ergonomics: parse the URL, adding a default https:// scheme if
+    // it's just a bare hostname
+    let urlstr = match instance_url.find('/') {
+        Some(_) => instance_url.to_owned(),
+        None => format!("https://{instance_url}"),
+    };
+    let url = match Url::parse(&urlstr) {
+        Ok(url) => Ok(url),
+        Err(e) => Err(ClientError::UrlParseError(
+            urlstr.clone(), e.to_string())),
+    }?;
+    let instance_url = url.as_str().trim_end_matches('/');
+
+    let login = Login::new(instance_url)?;
+
+    // Register the client and get its details
+    let app = login.register_client()?;
+    let app_token = login.get_token(&app, ClientCredentials)?;
+    let _app: Application = login.get("/api/v1/apps/verify_credentials",
+                                      &app_token.access_token)?;
+
+    // Get the URL the user will have to visit
+    let url = login.get_auth_url(&app)?;
+
+    // Print it
+    println!("Log in to the website {instance_url}/");
+    println!("and then visit this URL to authorise Mastodonochrome:");
+    println!("");
+    println!("{}", url);
+    println!("");
+    println!("If you click \"Authorise\" on that page, you should receive a response code.");
+    print!("Enter that code here: ");
+    std::io::stdout().flush().unwrap(); // FIXME
+
+    // Read the user's response
+    let code = {
+        let mut buf = String::new();
+        std::io::stdin().read_line(&mut buf).unwrap(); // FIXME
+        buf
+    };
+    let code = (&code).trim_end();
+
+    // Use that code to get the final user access token
+    let user_token = login.get_token(&app, AuthorizationCode(code))?;
+    let account: Account = login.get("/api/v1/accounts/verify_credentials",
+                                     &user_token.access_token)?;
+    let instance: Instance = login.get("/api/v2/instance",
+                                       &user_token.access_token)?;
+
+    println!("");
+    println!("Successfully logged in as {}@{}", account.id, instance.domain);
+
+    // Save everything!
+    let auth = AuthConfig {
+        account_id: account.id,
+        username: account.username,
+        instance_url: instance_url.to_owned(),
+        instance_domain: instance.domain,
+        client_id: app.client_id.unwrap(),
+        client_secret: app.client_secret.unwrap(),
+        user_token: user_token.access_token,
+    };
+    println!("JSON: {}", serde_json::to_string_pretty(&auth).unwrap());
+
+    Ok(())
+}
index 65b07dbac70990437b9d83ca0fee13a27016f338..eb5334eab2d58a514f3c103cee1d5c470319a866 100644 (file)
@@ -2,8 +2,10 @@ use clap::Parser;
 use std::fmt::Display;
 use std::process::ExitCode;
 
+use mastodonochrome::client::ClientError;
 use mastodonochrome::config::{ConfigLocation, ConfigError};
 use mastodonochrome::tui::{Tui, TuiError};
+use mastodonochrome::login::login;
 
 #[derive(Parser, Debug)]
 struct Args {
@@ -11,9 +13,14 @@ struct Args {
     #[arg(short, long)]
     config: Option<std::path::PathBuf>,
 
-    // Read-only mode: the client prevents accidental posting.
+    /// Read-only mode: the client prevents accidental posting.
     #[arg(short, long, action(clap::ArgAction::SetTrue))]
     readonly: bool,
+
+    /// Log in to a server, instead of running the main user interface.
+    /// Provide the top-level URL of the instance website.
+    #[arg(long, conflicts_with("readonly"))]
+    login: Option<String>,
 }
 
 #[derive(Debug)]
@@ -45,6 +52,7 @@ impl<E: TopLevelErrorCandidate> From<E> for TopLevelError {
 
 impl TopLevelErrorCandidate for ConfigError {}
 impl TopLevelErrorCandidate for TuiError {}
+impl TopLevelErrorCandidate for ClientError {}
 impl TopLevelErrorCandidate for clap::error::Error {
     // clap prints its own "error: "
     fn get_prefix() -> String { "".to_owned() }
@@ -56,7 +64,10 @@ fn main_inner() -> Result<(), TopLevelError> {
         None => ConfigLocation::default()?,
         Some(dir) => ConfigLocation::from_pathbuf(dir),
     };
-    Tui::run(&cfgloc, cli.readonly)?;
+    match cli.login {
+        None => Tui::run(&cfgloc, cli.readonly)?,
+        Some(ref server) => login(server)?,
+    }
     Ok(())
 }
 
index b1b9c77d37cbe0a10cd933fb85a37f10dd2d38a8..044961165afcd341b37ecef5ed25a72834d110be 100644 (file)
@@ -118,6 +118,16 @@ pub struct Account {
 pub struct Application {
     pub name: String,
     pub website: Option<String>,
+    pub client_id: Option<String>,
+    pub client_secret: Option<String>,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+pub struct Token {
+    pub access_token: String,
+    pub token_type: String,
+    pub scope: String,
+    pub created_at: u64, // Unix timestamp
 }
 
 #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, EnumIter)]