chiark / gitweb /
Refactor login so that Client does it all.
authorSimon Tatham <anakin@pobox.com>
Wed, 17 Jan 2024 13:08:48 +0000 (13:08 +0000)
committerSimon Tatham <anakin@pobox.com>
Fri, 19 Jan 2024 08:20:13 +0000 (08:20 +0000)
This sets up to allow login to happen within a Tui context, and also,
transfer seamlessly into a logged-in session once you've finished it.

src/auth.rs
src/client.rs
src/login.rs
src/tui.rs

index 9388f64246d3a01af6776f24e81d1d4c4a9f4287..1423d453f0b00980e05a6ee2724df96f517eb6cb 100644 (file)
@@ -26,7 +26,7 @@ impl std::fmt::Display for AuthError {
     }
 }
 
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, Default, Clone)]
 pub struct AuthConfig {
     pub account_id: Option<String>,
     pub username: Option<String>,
index 6c1fac6ef621226be20dd2b3781de52725720da6..79d753ec50aeb1503032504193108ba47e9a3e96 100644 (file)
@@ -3,8 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
 use std::fs::File;
 use std::io::{IoSlice, Read, Write};
 
-use super::auth::{AuthConfig, AuthError};
-use super::config::ConfigLocation;
+use super::auth::AuthConfig;
 use super::posting::Post;
 use super::types::*;
 
@@ -109,7 +108,7 @@ pub enum AccountFlag {
 }
 
 pub struct Client {
-    auth: AuthConfig,
+    pub auth: AuthConfig,
     client: reqwest::blocking::Client,
     accounts: HashMap<String, Account>,
     statuses: HashMap<String, Status>,
@@ -123,7 +122,6 @@ pub struct Client {
 
 #[derive(Debug, PartialEq, Eq, Clone)]
 pub enum ClientError {
-    Auth(AuthError),               // message
     InternalError(String),         // message
     UrlParseError(String, String), // url, message
     UrlError(String, String),      // url, message
@@ -132,12 +130,6 @@ pub enum ClientError {
 
 impl super::TopLevelErrorCandidate for ClientError {}
 
-impl From<AuthError> for ClientError {
-    fn from(err: AuthError) -> Self {
-        ClientError::Auth(err)
-    }
-}
-
 impl From<reqwest::Error> for ClientError {
     fn from(err: reqwest::Error) -> Self {
         match err.url() {
@@ -155,9 +147,6 @@ impl std::fmt::Display for ClientError {
         f: &mut std::fmt::Formatter<'_>,
     ) -> Result<(), std::fmt::Error> {
         match self {
-            ClientError::Auth(ref autherr) => {
-                write!(f, "unable to read authentication: {}", autherr)
-            }
             ClientError::InternalError(ref msg) => {
                 write!(f, "internal failure: {}", msg)
             }
@@ -456,10 +445,17 @@ pub fn execute_and_log_request(
     Ok((rsp, log))
 }
 
+const REDIRECT_MAGIC_STRING: &str = "urn:ietf:wg:oauth:2.0:oob";
+
+pub enum AppTokenType<'a> {
+    ClientCredentials,
+    AuthorizationCode(&'a str),
+}
+
 impl Client {
-    pub fn new(cfgloc: &ConfigLocation) -> Result<Self, ClientError> {
+    pub fn new(auth: AuthConfig) -> Result<Self, ClientError> {
         Ok(Client {
-            auth: AuthConfig::load(cfgloc)?,
+            auth,
             client: reqwest_client()?,
             accounts: HashMap::new(),
             statuses: HashMap::new(),
@@ -610,15 +606,19 @@ impl Client {
         self.polls.insert(poll.id.to_string(), poll.clone());
     }
 
-    pub fn account_by_id(&mut self, id: &str) -> Result<Account, ClientError> {
-        // If we're fetching our own account, we do it via the
+    fn account_by_id_internal(
+        &mut self,
+        id: Option<&str>,
+    ) -> Result<Account, ClientError> {
+        // If we're fetching our own account, or we don't know our
+        // account id yet because we're still logging in, we use the
         // verify_credentials request, which gets the extra
         // information. This also means we must repeat the request if
         // we've already received a copy of our own account details
         // via some other API and it doesn't contain the extra
         // information.
 
-        if let Some(ac) = self.accounts.get(id) {
+        if let Some(ac) = id.and_then(|id| self.accounts.get(id)) {
             if Some(&ac.id) != self.auth.account_id.as_ref()
                 || ac.source.is_some()
             {
@@ -626,10 +626,10 @@ impl Client {
             }
         }
 
-        let req = if Some(id) == self.auth.account_id.as_deref() {
+        let req = if id == self.auth.account_id.as_deref() {
             Req::get(&format!("api/v1/accounts/verify_credentials"))
         } else {
-            Req::get(&format!("api/v1/accounts/{id}"))
+            Req::get(&format!("api/v1/accounts/{}", id.unwrap()))
         };
         let (url, rsp) = self.api_request(req)?;
         let rspstatus = rsp.status();
@@ -643,7 +643,7 @@ impl Client {
                 }
             }
         }?;
-        if ac.id != id {
+        if id.is_some_and(|id| ac.id != id) {
             return Err(ClientError::UrlError(
                 url,
                 format!("request returned wrong account id {}", &ac.id),
@@ -653,6 +653,16 @@ impl Client {
         Ok(ac)
     }
 
+    pub fn account_by_id(&mut self, id: &str) -> Result<Account, ClientError> {
+        self.account_by_id_internal(Some(id))
+    }
+
+    pub fn verify_account_credentials(
+        &mut self,
+    ) -> Result<Account, ClientError> {
+        self.account_by_id_internal(None)
+    }
+
     pub fn poll_by_id(&mut self, id: &str) -> Result<Poll, ClientError> {
         if let Some(st) = self.polls.get(id) {
             return Ok(st.clone());
@@ -1482,4 +1492,107 @@ impl Client {
         self.cache_account(&ac);
         Ok(())
     }
+
+    pub fn register_client(&mut self) -> Result<Application, ClientError> {
+        let 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/");
+        let (url, rsp) = self.api_request(req)?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url, rspstatus.to_string()))
+        } else {
+            let app: Application = match serde_json::from_str(&rsp.text()?) {
+                Ok(app) => Ok(app),
+                Err(e) => Err(ClientError::UrlError(url, e.to_string())),
+            }?;
+            Ok(app)
+        }
+    }
+
+    pub fn get_app_token(
+        &mut 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 {
+            AppTokenType::ClientCredentials => {
+                req.param("grant_type", "client_credentials")
+            }
+            AppTokenType::AuthorizationCode(code) => req
+                .param("grant_type", "authorization_code")
+                .param("code", code)
+                .param("scope", "read write push"),
+        };
+        let (url, rsp) = self.api_request(req)?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url, rspstatus.to_string()))
+        } else {
+            let tok: Token = match serde_json::from_str(&rsp.text()?) {
+                Ok(tok) => Ok(tok),
+                Err(e) => Err(ClientError::UrlError(url, e.to_string())),
+            }?;
+            Ok(tok)
+        }
+    }
+
+    pub fn verify_app_credentials(
+        &mut self,
+    ) -> Result<Application, ClientError> {
+        let req = Req::get("api/v1/apps/verify_credentials");
+        let (url, rsp) = self.api_request(req)?;
+        let rspstatus = rsp.status();
+        if !rspstatus.is_success() {
+            Err(ClientError::UrlError(url, rspstatus.to_string()))
+        } else {
+            let app: Application = match serde_json::from_str(&rsp.text()?) {
+                Ok(app) => Ok(app),
+                Err(e) => Err(ClientError::UrlError(url, e.to_string())),
+            }?;
+            Ok(app)
+        }
+    }
+
+    pub 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.auth.instance_url.as_deref().expect("should have set up an instance URL before calling get_auth_url"))?;
+        Ok(url.to_string())
+    }
 }
index 487c6a365bc3e3aa0827bb3ffd6df1c651b0c76f..f17514a5ca86ef880900e382b49d216d95fc87e8 100644 (file)
@@ -3,155 +3,11 @@ use std::fs::File;
 use std::io::Write;
 
 use super::auth::{AuthConfig, AuthError};
-use super::client::{
-    execute_and_log_request, reqwest_client, ClientError, Req,
-};
+use super::client::{Client, ClientError, AppTokenType};
 use super::config::ConfigLocation;
-use super::types::{Account, Application, Instance, Token};
+use super::types::{Account, Application, Instance};
 use super::TopLevelError;
 
-struct Login {
-    instance_url: String,
-    client: reqwest::blocking::Client,
-    logfile: Option<File>,
-}
-
-enum AppTokenType<'a> {
-    ClientCredentials,
-    AuthorizationCode(&'a str),
-}
-use AppTokenType::*;
-
-const REDIRECT_MAGIC_STRING: &str = "urn:ietf:wg:oauth:2.0:oob";
-
-impl Login {
-    fn new(
-        instance_url: &str,
-        logfile: Option<File>,
-    ) -> Result<Self, ClientError> {
-        Ok(Login {
-            instance_url: instance_url.to_owned(),
-            client: reqwest_client()?,
-            logfile,
-        })
-    }
-
-    fn execute_request(
-        &mut self,
-        req: reqwest::blocking::RequestBuilder,
-    ) -> Result<reqwest::blocking::Response, ClientError> {
-        let (rsp, log) = execute_and_log_request(&self.client, req.build()?)?;
-        log.write_to(&mut self.logfile);
-        Ok(rsp)
-    }
-
-    fn register_client(&mut 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 = self.execute_request(req)?;
-        let rspstatus = rsp.status();
-        if !rspstatus.is_success() {
-            Err(ClientError::UrlError(url, rspstatus.to_string()))
-        } else {
-            let app: Application = match serde_json::from_str(&rsp.text()?) {
-                Ok(app) => Ok(app),
-                Err(e) => Err(ClientError::UrlError(url, e.to_string())),
-            }?;
-            Ok(app)
-        }
-    }
-
-    fn get_token(
-        &mut 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 = self.execute_request(req)?;
-        let rspstatus = rsp.status();
-        if !rspstatus.is_success() {
-            Err(ClientError::UrlError(url, rspstatus.to_string()))
-        } else {
-            let tok: Token = match serde_json::from_str(&rsp.text()?) {
-                Ok(tok) => Ok(tok),
-                Err(e) => Err(ClientError::UrlError(url, e.to_string())),
-            }?;
-            Ok(tok)
-        }
-    }
-
-    fn get<T: for<'a> serde::Deserialize<'a>>(
-        &mut self,
-        path: &str,
-        token: &str,
-    ) -> Result<T, ClientError> {
-        let (url, req) = Req::get(path).build(
-            &self.instance_url,
-            &self.client,
-            Some(token),
-        )?;
-        let rsp = self.execute_request(req)?;
-        let rspstatus = rsp.status();
-        if !rspstatus.is_success() {
-            Err(ClientError::UrlError(url, rspstatus.to_string()))
-        } else {
-            let tok: T = match serde_json::from_str(&rsp.text()?) {
-                Ok(tok) => Ok(tok),
-                Err(e) => Err(ClientError::UrlError(url, 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(
     cfgloc: &ConfigLocation,
     instance_url: &str,
@@ -181,16 +37,19 @@ pub fn login(
     }?;
     let instance_url = url.as_str().trim_end_matches('/');
 
-    let mut login = Login::new(instance_url, logfile)?;
+    let mut client = Client::new(AuthConfig::default())?;
+    client.set_logfile(logfile);
+    client.set_writable(true);
+    client.auth.instance_url = Some(instance_url.to_owned());
 
     // 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)?;
+    let app = client.register_client()?;
+    let app_token = client.get_app_token(&app, AppTokenType::ClientCredentials)?;
+    client.auth.user_token = Some(app_token.access_token);
+    let _app: Application = client.verify_app_credentials()?;
 
     // Get the URL the user will have to visit
-    let url = login.get_auth_url(&app)?;
+    let url = client.get_auth_url(&app)?;
 
     // Print it
     println!("Log in to the website {instance_url}/");
@@ -211,13 +70,10 @@ pub fn login(
     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)?;
+    let token = client.get_app_token(&app, AppTokenType::AuthorizationCode(code))?;
+    client.auth.user_token = Some(token.access_token.clone());
+    let account: Account = client.verify_account_credentials()?;
+    let instance: Instance = client.instance()?;
 
     println!();
     println!(
@@ -234,7 +90,7 @@ pub fn login(
         instance_domain: Some(instance.domain),
         client_id: Some(app.client_id.unwrap()),
         client_secret: Some(app.client_secret.unwrap()),
-        user_token: Some(user_token.access_token),
+        user_token: Some(token.access_token),
     };
 
     let mut json = serde_json::to_string_pretty(&auth).unwrap();
index 4f83d05b111f8934c7d8fd3491bc8cf8071d965f..66e8cde9ca17e1bf6a820f17f474ce66d440629b 100644 (file)
@@ -20,6 +20,7 @@ use std::time::Duration;
 use unicode_width::UnicodeWidthStr;
 
 use super::activity_stack::*;
+use super::auth::{AuthConfig, AuthError};
 use super::client::{
     Client, ClientError, FeedExtend, FeedId, StreamId, StreamUpdate,
 };
@@ -226,6 +227,13 @@ impl From<ClientError> for TuiError {
         }
     }
 }
+impl From<AuthError> for TuiError {
+    fn from(err: AuthError) -> Self {
+        TuiError {
+            message: format!("unable to read authentication: {}", err)
+        }
+    }
+}
 impl From<std::sync::mpsc::RecvError> for TuiError {
     fn from(err: std::sync::mpsc::RecvError) -> Self {
         TuiError {
@@ -269,7 +277,8 @@ impl Tui {
             }
         });
 
-        let mut client = Client::new(cfgloc)?;
+        let auth = AuthConfig::load(cfgloc)?;
+        let mut client = Client::new(auth)?;
         client.set_writable(!readonly);
         client.set_logfile(logfile);