feat: oauth provider (#86)
* chore: remove opener * chore: rename method * chore: add docs * chore: modify env
This commit is contained in:
parent
b0c213b5c0
commit
cf84557ebe
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ where
|
|||
}
|
||||
},
|
||||
},
|
||||
Err(err) => warn!("Send message failed error: {:?}", err),
|
||||
Err(err) => trace!("Send message failed error: {:?}", err),
|
||||
}
|
||||
|
||||
self.notify()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue