diff --git a/ee/tabby-schema/graphql/schema.graphql b/ee/tabby-schema/graphql/schema.graphql index 5c974ae08cf4..cc92d0bba7be 100644 --- a/ee/tabby-schema/graphql/schema.graphql +++ b/ee/tabby-schema/graphql/schema.graphql @@ -53,6 +53,7 @@ enum LicenseType { enum OAuthProvider { GITHUB GOOGLE + GITLAB } enum RepositoryKind { diff --git a/ee/tabby-schema/src/dao.rs b/ee/tabby-schema/src/dao.rs index 162f22c16397..98557f44adcd 100644 --- a/ee/tabby-schema/src/dao.rs +++ b/ee/tabby-schema/src/dao.rs @@ -326,6 +326,7 @@ impl DbEnum for OAuthProvider { match self { OAuthProvider::Google => "google", OAuthProvider::Github => "github", + OAuthProvider::Gitlab => "gitlab", } } @@ -333,6 +334,7 @@ impl DbEnum for OAuthProvider { match s { "github" => Ok(OAuthProvider::Github), "google" => Ok(OAuthProvider::Google), + "gitlab" => Ok(OAuthProvider::Gitlab), _ => bail!("Invalid OAuth credential type"), } } diff --git a/ee/tabby-schema/src/schema/auth.rs b/ee/tabby-schema/src/schema/auth.rs index 6abc06e2adff..dd57da4b3546 100644 --- a/ee/tabby-schema/src/schema/auth.rs +++ b/ee/tabby-schema/src/schema/auth.rs @@ -326,6 +326,7 @@ impl relay::NodeType for Invitation { pub enum OAuthProvider { Github, Google, + Gitlab, } #[derive(GraphQLObject)] diff --git a/ee/tabby-webserver/src/oauth/gitlab.rs b/ee/tabby-webserver/src/oauth/gitlab.rs new file mode 100644 index 000000000000..0e7daf22c444 --- /dev/null +++ b/ee/tabby-webserver/src/oauth/gitlab.rs @@ -0,0 +1,164 @@ +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; +use tabby_schema::auth::{AuthenticationService, OAuthCredential, OAuthProvider}; + +use super::OAuthClient; +use crate::bail; + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct GitlabOAuthResponse { + #[serde(default)] + access_token: String, + #[serde(default)] + scope: String, + #[serde(default)] + token_type: String, + + #[serde(default)] + expires_in: i32, + #[serde(default)] + created_at: u64, + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct GitlabUserEmail { + #[serde(default)] + email: String, + error: Option, +} + +pub struct GitlabClient { + client: reqwest::Client, + auth: Arc, +} + +impl GitlabClient { + pub fn new(auth: Arc) -> Self { + Self { + client: reqwest::Client::new(), + auth, + } + } + + async fn read_credential(&self) -> Result { + match self + .auth + .read_oauth_credential(OAuthProvider::Gitlab) + .await? + { + Some(credential) => Ok(credential), + None => bail!("No Gitlab OAuth credential found"), + } + } + + async fn exchange_access_token( + &self, + code: String, + credential: OAuthCredential, + redirect_uri: String, + ) -> Result { + let params: [(&str, &str); 5] = [ + ("client_id", &credential.client_id), + ("client_secret", &credential.client_secret), + ("code", &code), + ("grant_type", "authorization_code"), + ("redirect_uri", &redirect_uri), + ]; + let resp = self + .client + .post("https://gitlab.com/oauth/token") + .header(reqwest::header::ACCEPT, "application/json") + .form(¶ms) + .send() + .await? + .json::() + .await?; + + Ok(resp) + } +} + +#[async_trait] +impl OAuthClient for GitlabClient { + async fn fetch_user_email(&self, code: String) -> Result { + let credentials = self.read_credential().await?; + let redirect_uri = self.auth.oauth_callback_url(OAuthProvider::Gitlab).await?; + let token_resp = self + .exchange_access_token(code, credentials, redirect_uri) + .await?; + + if let Some(err) = token_resp.error { + bail!( + "Error while exchanging access token: {err} {}", + token_resp + .error_description + .map(|s| format!("({s})")) + .unwrap_or_default() + ); + } + + if token_resp.access_token.is_empty() { + bail!("Empty access token from Gitlab OAuth"); + } + + let resp = self + .client + .get("https://gitlab.com/api/v4/user") + .header(reqwest::header::USER_AGENT, "Tabby") + .header(reqwest::header::ACCEPT, "application/vnd.gitlab+json") + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", token_resp.access_token), + ) + .send() + .await?; + + let email = resp.json::().await?; + if let Some(error) = email.error { + bail!("{error}"); + } + Ok(email.email) + } + + async fn get_authorization_url(&self) -> Result { + let credentials = self.read_credential().await?; + let redirect_uri = self.auth.oauth_callback_url(OAuthProvider::Gitlab).await?; + create_authorization_url(&credentials.client_id, &redirect_uri) + } +} + +fn create_authorization_url(client_id: &str, redirect_uri: &str) -> Result { + let mut url = reqwest::Url::parse("https://gitlab.com/oauth/authorize")?; + let params = vec![ + ("client_id", client_id), + ("response_type", "code"), + ("scope", "api"), + ("redirect_uri", redirect_uri), + ]; + for (k, v) in params { + url.query_pairs_mut().append_pair(k, v); + } + Ok(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::create_authorization_url; + + #[test] + fn test_create_authorization_url() { + let url = + create_authorization_url("client_id", "http://localhost:8080/oauth/callback/gitlab") + .unwrap(); + assert_eq!(url, "https://gitlab.com/oauth/authorize?client_id=client_id&response_type=code&scope=api&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Foauth%2Fcallback%2Fgitlab"); + } +} diff --git a/ee/tabby-webserver/src/oauth/mod.rs b/ee/tabby-webserver/src/oauth/mod.rs index 6d00d2b074a8..5870b78a6720 100644 --- a/ee/tabby-webserver/src/oauth/mod.rs +++ b/ee/tabby-webserver/src/oauth/mod.rs @@ -1,4 +1,5 @@ mod github; +mod gitlab; mod google; use std::sync::Arc; @@ -9,6 +10,8 @@ use github::GithubClient; use google::GoogleClient; use tabby_schema::auth::{AuthenticationService, OAuthProvider}; +use self::gitlab::GitlabClient; + #[async_trait] pub trait OAuthClient: Send + Sync { async fn fetch_user_email(&self, code: String) -> Result; @@ -20,6 +23,7 @@ pub fn new_oauth_client( auth: Arc, ) -> Arc { match provider { + OAuthProvider::Gitlab => Arc::new(GitlabClient::new(auth)), OAuthProvider::Google => Arc::new(GoogleClient::new(auth)), OAuthProvider::Github => Arc::new(GithubClient::new(auth)), } diff --git a/ee/tabby-webserver/src/routes/oauth.rs b/ee/tabby-webserver/src/routes/oauth.rs index db9e94ee12ee..cd18b7950ede 100644 --- a/ee/tabby-webserver/src/routes/oauth.rs +++ b/ee/tabby-webserver/src/routes/oauth.rs @@ -21,6 +21,7 @@ pub fn routes(state: Arc) -> Router { .route("/providers", routing::get(providers_handler)) .route("/callback/github", routing::get(github_oauth_handler)) .route("/callback/google", routing::get(google_oauth_handler)) + .route("/callback/gitlab", routing::get(gitlab_oauth_handler)) .with_state(state) } @@ -106,6 +107,23 @@ async fn google_oauth_handler( ) } +#[derive(Deserialize)] +#[allow(dead_code)] +struct GitlabOAuthQueryParam { + code: String, + state: Option, +} + +async fn gitlab_oauth_handler( + State(state): State, + Query(param): Query, +) -> Redirect { + match_auth_result( + OAuthProvider::Gitlab, + state.oauth(param.code, OAuthProvider::Gitlab).await, + ) +} + fn match_auth_result( provider: OAuthProvider, result: Result, diff --git a/ee/tabby-webserver/src/service/auth.rs b/ee/tabby-webserver/src/service/auth.rs index ebb76883b79d..a72a7e36d1fc 100644 --- a/ee/tabby-webserver/src/service/auth.rs +++ b/ee/tabby-webserver/src/service/auth.rs @@ -459,6 +459,7 @@ impl AuthenticationService for AuthenticationServiceImpl { let url = match provider { OAuthProvider::Github => external_url + "/oauth/callback/github", OAuthProvider::Google => external_url + "/oauth/callback/google", + OAuthProvider::Gitlab => external_url + "/oauth/callback/gitlab", }; Ok(url) }