diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4a318231..eadec0ef 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -32,6 +32,7 @@ jobs: sed -i 's/GOTRUE_SMTP_USER=.*/GOTRUE_SMTP_USER=${{ secrets.GOTRUE_SMTP_USER }}/' .env sed -i 's/GOTRUE_SMTP_PASS=.*/GOTRUE_SMTP_PASS=${{ secrets.GOTRUE_SMTP_PASS }}/' .env sed -i 's/GOTRUE_SMTP_ADMIN_EMAIL=.*/GOTRUE_SMTP_ADMIN_EMAIL=${{ secrets.GOTRUE_SMTP_ADMIN_EMAIL }}/' .env + sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env - name: Run Docker-Compose run: | diff --git a/Cargo.lock b/Cargo.lock index 3a6bd1c4..15c25c32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,7 @@ dependencies = [ "lazy_static", "mime", "once_cell", + "opener", "openssl", "rand 0.8.5", "rcgen", @@ -843,7 +844,6 @@ dependencies = [ "gotrue-entity", "lib0", "mime", - "opener", "parking_lot", "reqwest", "scraper", diff --git a/Cargo.toml b/Cargo.toml index 8968513c..611633c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ assert-json-diff = "2.0.2" dotenv = "0.15.0" scraper = "0.17.1" client-api = { path = "libs/client-api", features = ["collab-sync"] } +opener = "0.6.1" [[bin]] name = "appflowy_cloud" diff --git a/build/run_local_server.sh b/build/run_local_server.sh index 11b5d00f..ac8e3aff 100755 --- a/build/run_local_server.sh +++ b/build/run_local_server.sh @@ -14,6 +14,10 @@ docker-compose --file ./docker-compose-dev.yml down # Start the Docker Compose setup export GOTRUE_MAILER_AUTOCONFIRM=true + +# Enable Google OAuth when running locally +export GOTRUE_EXTERNAL_GOOGLE_ENABLED=true + docker-compose --file ./docker-compose-dev.yml up -d --build # Keep pinging Postgres until it's ready to accept commands diff --git a/libs/client-api/Cargo.toml b/libs/client-api/Cargo.toml index cda3fa66..bc2a909f 100644 --- a/libs/client-api/Cargo.toml +++ b/libs/client-api/Cargo.toml @@ -14,7 +14,6 @@ gotrue = { path = "../gotrue" } gotrue-entity = { path = "../gotrue-entity" } shared_entity = { path = "../shared-entity" } database-entity = { path = "../database-entity" } -opener = "0.6.1" url = "2.4.1" tokio-stream = { version = "0.1.14" } parking_lot = "0.12.1" @@ -39,5 +38,8 @@ collab-define = { version = "0.1.0" } yrs = { version = "0.16.5", optional = true } lib0 = { version = "0.16.3", features = ["lib0-serde"], optional = true } + [features] collab-sync = ["collab", "yrs", "lib0"] + + diff --git a/libs/client-api/src/collab_sync/sink.rs b/libs/client-api/src/collab_sync/sink.rs index 1716cb9a..6667a340 100644 --- a/libs/client-api/src/collab_sync/sink.rs +++ b/libs/client-api/src/collab_sync/sink.rs @@ -286,7 +286,7 @@ where } }, }, - Err(err) => warn!("Send message failed error: {:?}", err), + Err(err) => trace!("Send message failed error: {:?}", err), } self.notify() diff --git a/libs/client-api/src/http.rs b/libs/client-api/src/http.rs index ca2b1b1e..bd2fc2bc 100644 --- a/libs/client-api/src/http.rs +++ b/libs/client-api/src/http.rs @@ -1,4 +1,4 @@ -use anyhow::anyhow; +use anyhow::{anyhow, Context}; use bytes::Bytes; use futures_util::StreamExt; use gotrue::grant::Grant; @@ -31,6 +31,18 @@ use shared_entity::error::AppError; use shared_entity::error_code::url_missing_param; use shared_entity::error_code::ErrorCode; +/// `Client` is responsible for managing communication with the GoTrue API and cloud storage. +/// +/// It provides methods to perform actions like signing in, signing out, refreshing tokens, +/// and interacting with file storage and collaboration objects. +/// +/// # Fields +/// - `cloud_client`: A `reqwest::Client` used for HTTP requests to the cloud. +/// - `gotrue_client`: A `gotrue::api::Client` used for interacting with the GoTrue API. +/// - `base_url`: The base URL for API requests. +/// - `ws_addr`: The WebSocket address for real-time communication. +/// - `token`: An `Arc>` managing the client's authentication token. +/// pub struct Client { pub(crate) cloud_client: reqwest::Client, pub(crate) gotrue_client: gotrue::api::Client, @@ -40,12 +52,19 @@ pub struct Client { } impl Client { - pub fn from(c: reqwest::Client, base_url: &str, ws_addr: &str, gotrue_url: &str) -> Self { + /// Constructs a new `Client` instance. + /// + /// # Parameters + /// - `base_url`: The base URL for API requests. + /// - `ws_addr`: The WebSocket address for real-time communication. + /// - `gotrue_url`: The URL for the GoTrue API. + pub fn new(base_url: &str, ws_addr: &str, gotrue_url: &str) -> Self { + let reqwest_client = reqwest::Client::new(); Self { base_url: base_url.to_string(), ws_addr: ws_addr.to_string(), - cloud_client: c.clone(), - gotrue_client: gotrue::api::Client::new(c, gotrue_url), + cloud_client: reqwest_client.clone(), + gotrue_client: gotrue::api::Client::new(reqwest_client, gotrue_url), token: Arc::new(RwLock::new(ClientToken::new())), } } @@ -54,8 +73,10 @@ impl Client { self.token.read().subscribe() } - // e.g. appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer - pub async fn sign_in_url(&self, url: &str) -> Result { + /// Attempts to sign in using a URL, extracting and validating the token parameters from the URL fragment. + /// It looks like, e.g., `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`. + /// + pub async fn sign_in_with_url(&self, url: &str) -> Result { let mut access_token: Option = None; let mut token_type: Option = None; let mut expires_in: Option = None; @@ -78,10 +99,10 @@ impl Client { token_type = Some(v.to_string()); }, "expires_in" => { - expires_in = Some(v.parse::()?); + expires_in = Some(v.parse::().context("parser expires_in failed")?); }, "expires_at" => { - expires_at = Some(v.parse::()?); + expires_at = Some(v.parse::().context("parser expires_at failed")?); }, "refresh_token" => { refresh_token = Some(v.to_string()); @@ -114,12 +135,82 @@ impl Client { Ok(new) } + /// Returns an OAuth URL by constructing the authorization URL for the specified provider. + /// + /// This asynchronous function communicates with the GoTrue client to retrieve settings and + /// validate the availability of the specified OAuth provider. If the provider is available, + /// it constructs and returns the OAuth URL. When the user opens the OAuth URL, it redirects to + /// the corresponding provider's OAuth web page. After the user is authenticated, the browser will open + /// a deep link to the AppFlowy app (iOS, macOS, etc.), which will call [Client::sign_in_with_url] to sign in. + /// + /// For example, the OAuth URL on Google looks like `https://appflowy.io/authorize?provider=google`. + /// The deep link looks like `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`. + + /// # Parameters + /// - `provider`: A reference to an `OAuthProvider` indicating which OAuth provider to use for login. + /// + /// # Returns + /// - `Ok(String)`: A `String` containing the constructed authorization URL if the specified provider is available. + /// - `Err(AppError)`: An `AppError` indicating either the OAuth provider is invalid or other issues occurred while fetching settings. + /// + pub async fn generate_oauth_url_with_provider( + &self, + provider: &OAuthProvider, + ) -> Result { + let settings = self.gotrue_client.settings().await?; + if !settings.external.has_provider(provider) { + return Err(ErrorCode::InvalidOAuthProvider.into()); + } + + Ok(format!( + "{}/authorize?provider={}", + self.gotrue_client.base_url, + provider.as_str(), + )) + } + + /// Returns an OAuth URL by constructing the authorization URL for the specified provider. + /// The URL looks like, e.g., `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`. + /// + pub async fn generate_sign_in_url_with_email( + &self, + admin_user_email: &str, + admin_user_password: &str, + user_email: &str, + ) -> Result { + let admin_token = self + .gotrue_client + .token(&Grant::Password(PasswordGrant { + email: admin_user_email.to_string(), + password: admin_user_password.to_string(), + })) + .await?; + + let admin_user_params: GenerateLinkParams = GenerateLinkParams { + email: user_email.to_string(), + ..Default::default() + }; + + let link_resp = self + .gotrue_client + .generate_link(&admin_token.access_token, &admin_user_params) + .await?; + assert_eq!(link_resp.email, user_email); + + let action_link = link_resp.action_link; + let resp = reqwest::Client::new().get(action_link).send().await?; + let resp_text = resp.text().await?; + Ok(extract_sign_in_url(&resp_text)?) + } + + #[inline] async fn verify_token(&self, access_token: &str) -> Result<(User, bool), AppError> { let user = self.gotrue_client.user_info(access_token).await?; let is_new = self.verify_token_cloud(access_token).await?; Ok((user, is_new)) } + #[inline] async fn verify_token_cloud(&self, access_token: &str) -> Result { let url = format!("{}/api/user/verify/{}", self.base_url, access_token); let resp = self.cloud_client.get(&url).send().await?; @@ -169,6 +260,15 @@ impl Client { self.token.clone() } + /// Retrieves the expiration timestamp of the current token. + /// + /// This function attempts to read the current token and, if successful, returns the expiration timestamp. + /// + /// # Returns + /// - `Ok(i64)`: An `i64` representing the expiration timestamp of the token. + /// - `Err(AppError)`: An `AppError` indicating either an inability to read the token or that the user is not logged in. + /// + #[inline] pub fn token_expires_at(&self) -> Result { match &self.token.try_read() { None => Err(AppError::new(ErrorCode::Unhandled, "Failed to read token")), @@ -176,6 +276,14 @@ impl Client { } } + /// Retrieves the access token string. + /// + /// This function attempts to read the current token and, if successful, returns the access token string. + /// + /// # Returns + /// - `Ok(String)`: A `String` containing the access token. + /// - `Err(AppError)`: An `AppError` indicating either an inability to read the token or that the user is not logged in. + /// pub fn access_token(&self) -> Result { match &self.token.try_read() { None => Err(AppError::new(ErrorCode::Unhandled, "Failed to read token")), @@ -189,21 +297,6 @@ impl Client { } } - pub async fn oauth_login(&self, provider: &OAuthProvider) -> Result<(), AppError> { - let settings = self.gotrue_client.settings().await?; - if !settings.external.has_provider(provider) { - return Err(ErrorCode::InvalidOAuthProvider.into()); - } - - let oauth_url = format!( - "{}/authorize?provider={}", - self.gotrue_client.base_url, - provider.as_str(), - ); - opener::open(oauth_url)?; - Ok(()) - } - pub async fn profile(&self) -> Result { let url = format!("{}/api/user/profile", self.base_url); let resp = self @@ -301,6 +394,11 @@ impl Client { Ok(is_new) } + /// Refreshes the access token using the stored refresh token. + /// + /// This function attempts to refresh the access token by sending a request to the authentication server + /// using the stored refresh token. If successful, it updates the stored access token with the new one + /// received from the server. pub async fn refresh(&self) -> Result<(), AppError> { let refresh_token = self .token @@ -415,37 +513,6 @@ impl Client { Ok(format!("{}/{}/{}", self.ws_addr, access_token, device_id)) } - pub async fn generate_sign_in_callback_url( - &self, - admin_user_email: &str, - admin_user_password: &str, - user_email: &str, - ) -> Result { - let admin_token = self - .gotrue_client - .token(&Grant::Password(PasswordGrant { - email: admin_user_email.to_string(), - password: admin_user_password.to_string(), - })) - .await?; - - let admin_user_params: GenerateLinkParams = GenerateLinkParams { - email: user_email.to_string(), - ..Default::default() - }; - - let link_resp = self - .gotrue_client - .generate_link(&admin_token.access_token, &admin_user_params) - .await?; - assert_eq!(link_resp.email, user_email); - - let action_link = link_resp.action_link; - let resp = reqwest::Client::new().get(action_link).send().await?; - let resp_text = resp.text().await?; - Ok(extract_sign_in_url(&resp_text)?) - } - pub async fn put_file_storage_object( &self, path: &str, diff --git a/tests/client/sign_in.rs b/tests/client/sign_in.rs index 0f7b7cb6..ed7f6271 100644 --- a/tests/client/sign_in.rs +++ b/tests/client/sign_in.rs @@ -90,7 +90,7 @@ async fn sign_in_success() { async fn sign_in_with_invalid_url() { let url_str = "appflowy-flutter://#access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTQ1ODIyMjMsInN1YiI6Ijk5MGM2NDNjLTMyMWEtNGNmMi04OWY1LTNhNmJhZGFjMTg5NCIsImVtYWlsIjoiNG5uaWhpbGF0ZWRAZ21haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJnb29nbGUiLCJwcm92aWRlcnMiOlsiZ29vZ2xlIl19LCJ1c2VyX21ldGFkYXRhIjp7ImF2YXRhcl91cmwiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NJdGZpa28xX0lpMmZiNzM4VnpGekViLVBqT0NCY3FUQzdrNjVIX0hnRTQwOVk9czk2LWMiLCJlbWFpbCI6IjRubmloaWxhdGVkQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmdWxsX25hbWUiOiJmdSB6aXhpYW5nIiwiaXNzIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vdXNlcmluZm8vdjIvbWUiLCJuYW1lIjoiZnUgeml4aWFuZyIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NJdGZpa28xX0lpMmZiNzM4VnpGekViLVBqT0NCY3FUQzdrNjVIX0hnRTQwOVk9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTQ5OTYxMDMxOTYxNjE0NTcyNSIsInN1YiI6IjEwMTQ5OTYxMDMxOTYxNjE0NTcyNSJ9LCJyb2xlIjoiIn0.I-7j-Tdj62P56zhzEqvBc7cHMldv5MA_MM7xtrBibbE&expires_in=3600&provider_token=ya29.a0AfB_byCovXs1CUiC9_f9VBTupQPsIxwh9aSlOg0PLYJvv1x1zvVfssrQfW6_Aq9no7EKpCzFUCLElOvK1Xz4x4K5r7tug79tr5b1yiOoUMWTeWTXyV61fZHQbZ9vscAiyKYtq5NqYTiytHcQEFlKr7UMfu6BTbKsUwaCgYKAaISARISFQGOcNnC0Vsx2QCAXgYO3XbfcF91WQ0169&refresh_token=Hi3Jc3I_pj9YrexcR91i5g&token_type=bearer"; let c = client_api_client(); - match c.sign_in_url(url_str).await { + match c.sign_in_with_url(url_str).await { Ok(_) => panic!("should not be ok"), Err(e) => { assert_eq!(e.code, ErrorCode::OAuthError); @@ -106,8 +106,8 @@ async fn sign_in_with_url() { let c = client_api_client(); let user_email = generate_unique_email(); let url_str = c - .generate_sign_in_callback_url(&ADMIN_USER.email, &ADMIN_USER.password, &user_email) + .generate_sign_in_url_with_email(&ADMIN_USER.email, &ADMIN_USER.password, &user_email) .await .unwrap(); - let _ = c.sign_in_url(&url_str).await.unwrap(); + let _ = c.sign_in_with_url(&url_str).await.unwrap(); } diff --git a/tests/client/sign_up.rs b/tests/client/sign_up.rs index 62713505..c95e5a82 100644 --- a/tests/client/sign_up.rs +++ b/tests/client/sign_up.rs @@ -48,14 +48,24 @@ async fn sign_up_but_existing_user() { #[tokio::test] async fn sign_up_oauth_not_available() { let c = client_api_client(); + let err = c + .generate_oauth_url_with_provider(&OAuthProvider::Zoom) + .await + .err() + .unwrap(); assert_eq!( // Change Zoom to any other valid OAuth provider // to manually open the browser and login - c.oauth_login(&OAuthProvider::Zoom) - .await - .err() - .unwrap() - .code, + err.code, ErrorCode::InvalidOAuthProvider ); } + +#[tokio::test] +async fn sign_up_with_google_oauth() { + let c = client_api_client(); + let _ = c + .generate_oauth_url_with_provider(&OAuthProvider::Google) + .await + .unwrap(); +} diff --git a/tests/gotrue/admin.rs b/tests/gotrue/admin.rs index c6e7edec..886b4c59 100644 --- a/tests/gotrue/admin.rs +++ b/tests/gotrue/admin.rs @@ -89,7 +89,10 @@ async fn admin_generate_link_and_user_sign_in() { let appflowy_sign_in_url = extract_sign_in_url(&resp_text).unwrap(); let client = client_api_client(); - let is_new = client.sign_in_url(&appflowy_sign_in_url).await.unwrap(); + let is_new = client + .sign_in_with_url(&appflowy_sign_in_url) + .await + .unwrap(); assert!(is_new); let workspaces = client.workspaces().await.unwrap(); diff --git a/tests/main.rs b/tests/main.rs index 46b809d1..916bb03b 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -8,10 +8,5 @@ mod gotrue; mod realtime; pub fn client_api_client() -> Client { - Client::from( - reqwest::Client::new(), - LOCALHOST_URL, - LOCALHOST_WS, - LOCALHOST_GOTRUE, - ) + Client::new(LOCALHOST_URL, LOCALHOST_WS, LOCALHOST_GOTRUE) }