feat: oauth provider (#86)

* chore: remove opener

* chore: rename method

* chore: add docs

* chore: modify env
This commit is contained in:
Nathan.fooo 2023-10-03 22:06:07 +08:00 committed by GitHub
parent b0c213b5c0
commit cf84557ebe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 155 additions and 72 deletions

View File

@ -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: |

2
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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

View File

@ -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"]

View File

@ -286,7 +286,7 @@ where
}
},
},
Err(err) => warn!("Send message failed error: {:?}", err),
Err(err) => trace!("Send message failed error: {:?}", err),
}
self.notify()

View File

@ -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<RwLock<ClientToken>>` 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<bool, AppError> {
/// 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<bool, AppError> {
let mut access_token: Option<String> = None;
let mut token_type: Option<String> = None;
let mut expires_in: Option<i64> = None;
@ -78,10 +99,10 @@ impl Client {
token_type = Some(v.to_string());
},
"expires_in" => {
expires_in = Some(v.parse::<i64>()?);
expires_in = Some(v.parse::<i64>().context("parser expires_in failed")?);
},
"expires_at" => {
expires_at = Some(v.parse::<i64>()?);
expires_at = Some(v.parse::<i64>().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<String, AppError> {
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<String, AppError> {
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<bool, AppError> {
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<i64, AppError> {
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<String, AppError> {
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<AFUserProfileView, AppError> {
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<String, AppError> {
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,

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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();

View File

@ -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)
}