From 1126ca30b0d9d29bdd7e9df4f5db1a073c7d0e84 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 28 Oct 2025 18:53:54 +0530 Subject: [PATCH] [WEB-5262] feat: gitea sso (#8022) * Feature/7137/gitea sso (#7940) * added gitea auth to admin panel with configs , added api calls * added gitea to oauth root (for signup and signin) * removed log * replace github oauth with gitea ouath error messages * added gitea to auth root * fix: update token expiration handling and remove unused variable in Gitea callback * fix: include Gitea in OAuth enabled checks * fix: improve error handling when fetching emails from Gitea * chore : remove logs and add semicolons * refactor: update Gitea authentication components and imports for consistency * fix: enhance Gitea authentication form to auto-populate host value and improve OAuth checks * refactor: enhance Gitea OAuth provider with improved error handling and URL validation * fix: update authentication success messages to check for string value "1" --------- Co-authored-by: Shivam Jain Co-authored-by: Prateek Shourya --- .../(dashboard)/authentication/gitea/form.tsx | 210 ++++++++++++++++++ .../authentication/gitea/layout.tsx | 10 + .../(dashboard)/authentication/gitea/page.tsx | 104 +++++++++ .../authentication/github/page.tsx | 2 +- .../authentication/gitlab/page.tsx | 2 +- .../authentication/google/page.tsx | 2 +- .../authentication/authentication-modes.tsx | 9 + .../authentication/gitea-config.tsx | 58 +++++ apps/admin/public/logos/gitea-logo.svg | 1 + .../api/plane/authentication/adapter/error.py | 2 + .../api/plane/authentication/adapter/oauth.py | 2 + .../authentication/provider/oauth/gitea.py | 171 ++++++++++++++ apps/api/plane/authentication/urls.py | 17 ++ .../plane/authentication/views/__init__.py | 3 + .../plane/authentication/views/app/gitea.py | 109 +++++++++ .../plane/authentication/views/space/gitea.py | 100 +++++++++ apps/api/plane/license/api/views/instance.py | 6 + .../management/commands/configure_instance.py | 68 +++++- .../account/auth-forms/auth-root.tsx | 17 +- apps/space/public/logos/gitea-logo.svg | 1 + apps/web/public/logos/gitea-logo.svg | 1 + packages/types/src/instance/auth.ts | 8 +- packages/types/src/instance/base.ts | 1 + 23 files changed, 897 insertions(+), 7 deletions(-) create mode 100644 apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx create mode 100644 apps/admin/app/(all)/(dashboard)/authentication/gitea/layout.tsx create mode 100644 apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx create mode 100644 apps/admin/core/components/authentication/gitea-config.tsx create mode 100644 apps/admin/public/logos/gitea-logo.svg create mode 100644 apps/api/plane/authentication/provider/oauth/gitea.py create mode 100644 apps/api/plane/authentication/views/app/gitea.py create mode 100644 apps/api/plane/authentication/views/space/gitea.py create mode 100644 apps/space/public/logos/gitea-logo.svg create mode 100644 apps/web/public/logos/gitea-logo.svg diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx new file mode 100644 index 000000000..fefc1ac89 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/form.tsx @@ -0,0 +1,210 @@ +"use client"; + +import type { FC } from "react"; +import { useState } from "react"; +import { isEmpty } from "lodash-es"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceGiteaAuthenticationConfigurationKeys } from "@plane/types"; +import { Button, getButtonStyling } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GiteaConfigFormValues = Record; + +export const InstanceGiteaConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com", + GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"], + GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GITEA_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GITEA_HOST", + type: "text", + label: "Gitea Host", + description: ( + <>Use the URL of your Gitea instance. For the official Gitea instance, use "https://gitea.com". + ), + placeholder: "https://gitea.com", + error: Boolean(errors.GITEA_HOST), + required: true, + }, + { + key: "GITEA_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + You will get this from your{" "} + + Gitea OAuth application settings. + + + ), + placeholder: "70a44354520df8bd9bcd", + error: Boolean(errors.GITEA_CLIENT_ID), + required: true, + }, + { + key: "GITEA_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + Your client secret is also found in your{" "} + + Gitea OAuth application settings. + + + ), + placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb", + error: Boolean(errors.GITEA_CLIENT_SECRET), + required: true, + }, + ]; + + const GITEA_SERVICE_FIELD: TCopyField[] = [ + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/gitea/callback/`, + description: ( + <> + We will auto-generate this. Paste this into your Authorized Callback URI{" "} + field{" "} + + here. + + + ), + }, + ]; + + const onSubmit = async (formData: GiteaConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Gitea authentication is configured. You should test it now.", + }); + reset({ + GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value, + GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value, + GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Gitea-provided details for Plane
+ {GITEA_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for Gitea
+ {GITEA_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/layout.tsx new file mode 100644 index 000000000..9526d13fb --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Gitea Authentication - God Mode", +}; + +export default function GiteaAuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx new file mode 100644 index 000000000..74834638f --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitea/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane internal packages +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import giteaLogo from "@/public/logos/gitea-logo.svg"; +//local components +import { InstanceGiteaConfigForm } from "./form"; + +const InstanceGiteaAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // theme + const { resolvedTheme } = useTheme(); + // config + const enableGiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? ""; + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GITEA_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + const isGiteaEnabled = enableGiteaConfig === "1"; + + return ( + <> +
+
+ } + config={ + { + updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGiteaAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx index 5709ba4ba..4a1d19695 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx @@ -44,7 +44,7 @@ const InstanceGithubAuthenticationPage = observer(() => { loading: "Saving Configuration...", success: { title: "Configuration saved", - message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`, + message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`, }, error: { title: "Error", diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx index ae85168ae..907c35b8b 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx @@ -38,7 +38,7 @@ const InstanceGitlabAuthenticationPage = observer(() => { loading: "Saving Configuration...", success: { title: "Configuration saved", - message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`, + message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`, }, error: { title: "Error", diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx index d6ca370d4..f3c6c2808 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx @@ -38,7 +38,7 @@ const InstanceGoogleAuthenticationPage = observer(() => { loading: "Saving Configuration...", success: { title: "Configuration saved", - message: () => `Google authentication is now ${value ? "active" : "disabled"}.`, + message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`, }, error: { title: "Error", diff --git a/apps/admin/ce/components/authentication/authentication-modes.tsx b/apps/admin/ce/components/authentication/authentication-modes.tsx index 386e0c05e..0936105f9 100644 --- a/apps/admin/ce/components/authentication/authentication-modes.tsx +++ b/apps/admin/ce/components/authentication/authentication-modes.tsx @@ -12,6 +12,7 @@ import { resolveGeneralTheme } from "@plane/utils"; // components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; +import { GiteaConfiguration } from "@/components/authentication/gitea-config"; import { GithubConfiguration } from "@/components/authentication/github-config"; import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; import { GoogleConfiguration } from "@/components/authentication/google-config"; @@ -19,6 +20,7 @@ import { PasswordLoginConfiguration } from "@/components/authentication/password // plane admin components import { UpgradeButton } from "@/plane-admin/components/common"; // assets +import giteaLogo from "@/public/logos/gitea-logo.svg"; import githubLightModeImage from "@/public/logos/github-black.png"; import githubDarkModeImage from "@/public/logos/github-white.png"; import GitlabLogo from "@/public/logos/gitlab-logo.svg"; @@ -80,6 +82,13 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => icon: GitLab Logo, config: , }, + { + key: "gitea", + name: "Gitea", + description: "Allow members to log in or sign up to plane with their Gitea accounts.", + icon: Gitea Logo, + config: , + }, { key: "oidc", name: "OIDC", diff --git a/apps/admin/core/components/authentication/gitea-config.tsx b/apps/admin/core/components/authentication/gitea-config.tsx new file mode 100644 index 000000000..8b0b9b374 --- /dev/null +++ b/apps/admin/core/components/authentication/gitea-config.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// plane internal packages +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const GiteaConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const GiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? ""; + const GiteaConfigured = + !!formattedConfig?.GITEA_HOST && !!formattedConfig?.GITEA_CLIENT_ID && !!formattedConfig?.GITEA_CLIENT_SECRET; + + return ( + <> + {GiteaConfigured ? ( +
+ + Edit + + { + Boolean(parseInt(GiteaConfig)) === true + ? updateConfig("IS_GITEA_ENABLED", "0") + : updateConfig("IS_GITEA_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/apps/admin/public/logos/gitea-logo.svg b/apps/admin/public/logos/gitea-logo.svg new file mode 100644 index 000000000..43291345d --- /dev/null +++ b/apps/admin/public/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index c8622277e..25a7cf567 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -38,9 +38,11 @@ AUTHENTICATION_ERROR_CODES = { "GITHUB_NOT_CONFIGURED": 5110, "GITHUB_USER_NOT_IN_ORG": 5122, "GITLAB_NOT_CONFIGURED": 5111, + "GITEA_NOT_CONFIGURED": 5112, "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITLAB_OAUTH_PROVIDER_ERROR": 5121, + "GITEA_OAUTH_PROVIDER_ERROR": 5123, # Reset Password "INVALID_PASSWORD_TOKEN": 5125, "EXPIRED_PASSWORD_TOKEN": 5130, diff --git a/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py index ed1201097..d8e423d0e 100644 --- a/apps/api/plane/authentication/adapter/oauth.py +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -48,6 +48,8 @@ class OauthAdapter(Adapter): return "GITHUB_OAUTH_PROVIDER_ERROR" elif self.provider == "gitlab": return "GITLAB_OAUTH_PROVIDER_ERROR" + elif self.provider == "gitea": + return "GITEA_OAUTH_PROVIDER_ERROR" else: return "OAUTH_NOT_CONFIGURED" diff --git a/apps/api/plane/authentication/provider/oauth/gitea.py b/apps/api/plane/authentication/provider/oauth/gitea.py new file mode 100644 index 000000000..ba7d3d16b --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/gitea.py @@ -0,0 +1,171 @@ +import os +from datetime import datetime, timedelta +from urllib.parse import urlencode, urlparse +import pytz +import requests + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class GiteaOAuthProvider(OauthAdapter): + provider = "gitea" + scope = "openid email profile" + + def __init__(self, request, code=None, state=None, callback=None): + (GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, GITEA_HOST) = get_configuration_value( + [ + { + "key": "GITEA_CLIENT_ID", + "default": os.environ.get("GITEA_CLIENT_ID"), + }, + { + "key": "GITEA_CLIENT_SECRET", + "default": os.environ.get("GITEA_CLIENT_SECRET"), + }, + { + "key": "GITEA_HOST", + "default": os.environ.get("GITEA_HOST"), + }, + ] + ) + + if not (GITEA_CLIENT_ID and GITEA_CLIENT_SECRET and GITEA_HOST): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_NOT_CONFIGURED"], + error_message="GITEA_NOT_CONFIGURED", + ) + + # Enforce scheme and normalize trailing slash(es) + parsed = urlparse(GITEA_HOST) + if not parsed.scheme or parsed.scheme not in ("https", "http"): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_NOT_CONFIGURED"], + error_message="GITEA_NOT_CONFIGURED", # avoid leaking details to query params + ) + GITEA_HOST = GITEA_HOST.rstrip("/") + + # Set URLs based on the host + self.token_url = f"{GITEA_HOST}/login/oauth/access_token" + self.userinfo_url = f"{GITEA_HOST}/api/v1/user" + + client_id = GITEA_CLIENT_ID + client_secret = GITEA_CLIENT_SECRET + + redirect_uri = f"{'https' if request.is_secure() else 'http'}://{request.get_host()}/auth/gitea/callback/" + url_params = { + "client_id": client_id, + "scope": self.scope, + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + } + auth_url = f"{GITEA_HOST}/login/oauth/authorize?{urlencode(url_params)}" + + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + callback=callback, + ) + + def set_token_data(self): + data = { + "code": self.code, + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + } + headers = {"Accept": "application/json"} + token_response = self.get_user_token(data=data, headers=headers) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.now(tz=pytz.utc) + timedelta(seconds=token_response.get("expires_in")) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("refresh_token_expired_at"), tz=pytz.utc + ) + if token_response.get("refresh_token_expired_at") + else None + ), + "id_token": token_response.get("id_token", ""), + } + ) + + def __get_email(self, headers): + try: + # Gitea may not provide email in user response, so fetch it separately + emails_url = f"{self.userinfo_url}/emails" + response = requests.get(emails_url, headers=headers) + if not response.ok: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR: Failed to fetch emails", + ) + emails_response = response.json() + + if not emails_response: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR: No emails found", + ) + # Prefer primary+verified, then any verified, then primary, else first + email = next((e.get("email") for e in emails_response if e.get("primary") and e.get("verified")), None) + if not email: + email = next((e.get("email") for e in emails_response if e.get("verified")), None) + if not email: + email = next((e.get("email") for e in emails_response if e.get("primary")), None) + if not email and emails_response: + # If no primary email, use the first one + email = emails_response[0].get("email") + return email + except requests.RequestException: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR: Exception occurred while fetching emails", + ) + + def set_user_data(self): + user_info_response = self.get_user_response() + headers = { + "Authorization": f"Bearer {self.token_data.get('access_token')}", + "Accept": "application/json", + } + + # Get email if not provided in user info + email = user_info_response.get("email") + if not email: + email = self.__get_email(headers=headers) + + super().set_user_data( + { + "email": email, + "user": { + "provider_id": str(user_info_response.get("id")), + "email": email, + "avatar": user_info_response.get("avatar_url"), + "first_name": user_info_response.get("full_name") or user_info_response.get("login"), + "last_name": "", # Gitea doesn't provide separate first/last name + "is_password_autoset": True, + }, + } + ) \ No newline at end of file diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index d8b5799de..64b8e654c 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -36,6 +36,10 @@ from .views import ( SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint, SignOutAuthSpaceEndpoint, + GiteaCallbackEndpoint, + GiteaOauthInitiateEndpoint, + GiteaCallbackSpaceEndpoint, + GiteaOauthInitiateSpaceEndpoint, ) urlpatterns = [ @@ -129,4 +133,17 @@ urlpatterns = [ ), path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"), path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"), + ## Gitea Oauth + path("gitea/", GiteaOauthInitiateEndpoint.as_view(), name="gitea-initiate"), + path("gitea/callback/", GiteaCallbackEndpoint.as_view(), name="gitea-callback"), + path( + "spaces/gitea/", + GiteaOauthInitiateSpaceEndpoint.as_view(), + name="space-gitea-initiate", + ), + path( + "spaces/gitea/callback/", + GiteaCallbackSpaceEndpoint.as_view(), + name="space-gitea-callback", + ), ] diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index 24ae1f673..2595d2e75 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -5,6 +5,7 @@ from .app.check import EmailCheckEndpoint from .app.email import SignInAuthEndpoint, SignUpAuthEndpoint from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint +from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint @@ -17,6 +18,8 @@ from .space.github import GitHubCallbackSpaceEndpoint, GitHubOauthInitiateSpaceE from .space.gitlab import GitLabCallbackSpaceEndpoint, GitLabOauthInitiateSpaceEndpoint +from .space.gitea import GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint + from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint from .space.magic import ( diff --git a/apps/api/plane/authentication/views/app/gitea.py b/apps/api/plane/authentication/views/app/gitea.py new file mode 100644 index 000000000..fd12f8b33 --- /dev/null +++ b/apps/api/plane/authentication/views/app/gitea.py @@ -0,0 +1,109 @@ +import uuid +from urllib.parse import urlencode, urljoin + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.gitea import GiteaOAuthProvider +from plane.authentication.utils.login import user_login +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) +from plane.utils.path_validator import validate_next_path + + +class GiteaOauthInitiateEndpoint(View): + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_app=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(validate_next_path(next_path)) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = urljoin( + base_host(request=request, is_app=True), "?" + urlencode(params) + ) + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = GiteaOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = urljoin( + base_host(request=request, is_app=True), "?" + urlencode(params) + ) + return HttpResponseRedirect(url) + + +class GiteaCallbackEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin(base_host, "?" + urlencode(params)) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = urljoin(base_host, "?" + urlencode(params)) + return HttpResponseRedirect(url) + + try: + provider = GiteaOAuthProvider( + request=request, code=code, callback=post_user_auth_workflow + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + if next_path: + path = str(validate_next_path(next_path)) + else: + path = get_redirection_path(user=user) + # redirect to referer path + url = urljoin(base_host, path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = urljoin(base_host, "?" + urlencode(params)) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/gitea.py b/apps/api/plane/authentication/views/space/gitea.py new file mode 100644 index 000000000..497a1ecc0 --- /dev/null +++ b/apps/api/plane/authentication/views/space/gitea.py @@ -0,0 +1,100 @@ +# Python imports +import uuid +from urllib.parse import urlencode + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.gitea import GiteaOAuthProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.utils.path_validator import validate_next_path + + +class GiteaOauthInitiateSpaceEndpoint(View): + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(validate_next_path(next_path)) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GiteaOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + +class GiteaCallbackSpaceEndpoint(View): + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"], + error_message="GITEA_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + provider = GiteaOAuthProvider(request=request, code=code) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # Process workspace and project invitations + # redirect to referer path + url = ( + f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + ) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(validate_next_path(next_path)) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index c598acfef..23eeebec1 100644 --- a/apps/api/plane/license/api/views/instance.py +++ b/apps/api/plane/license/api/views/instance.py @@ -50,6 +50,7 @@ class InstanceEndpoint(BaseAPIView): IS_GITHUB_ENABLED, GITHUB_APP_NAME, IS_GITLAB_ENABLED, + IS_GITEA_ENABLED, EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN, ENABLE_EMAIL_PASSWORD, @@ -86,6 +87,10 @@ class InstanceEndpoint(BaseAPIView): "key": "IS_GITLAB_ENABLED", "default": os.environ.get("IS_GITLAB_ENABLED", "0"), }, + { + "key": "IS_GITEA_ENABLED", + "default": os.environ.get("IS_GITEA_ENABLED", "0"), + }, {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, { "key": "ENABLE_MAGIC_LINK_LOGIN", @@ -134,6 +139,7 @@ class InstanceEndpoint(BaseAPIView): data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1" + data["is_gitea_enabled"] = IS_GITEA_ENABLED == "1" data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1" data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1" diff --git a/apps/api/plane/license/management/commands/configure_instance.py b/apps/api/plane/license/management/commands/configure_instance.py index 5611eec52..81c8fc89e 100644 --- a/apps/api/plane/license/management/commands/configure_instance.py +++ b/apps/api/plane/license/management/commands/configure_instance.py @@ -187,6 +187,30 @@ class Command(BaseCommand): "category": "INTERCOM", "is_encrypted": False, }, + { + "key": "IS_GITEA_ENABLED", + "value": os.environ.get("IS_GITEA_ENABLED", "0"), + "category": "GITEA", + "is_encrypted": False, + }, + { + "key": "GITEA_HOST", + "value": os.environ.get("GITEA_HOST"), + "category": "GITEA", + "is_encrypted": False, + }, + { + "key": "GITEA_CLIENT_ID", + "value": os.environ.get("GITEA_CLIENT_ID"), + "category": "GITEA", + "is_encrypted": False, + }, + { + "key": "GITEA_CLIENT_SECRET", + "value": os.environ.get("GITEA_CLIENT_SECRET"), + "category": "GITEA", + "is_encrypted": True, + }, ] for item in config_keys: @@ -203,7 +227,7 @@ class Command(BaseCommand): else: self.stdout.write(self.style.WARNING(f"{obj.key} configuration already exists")) - keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"] + keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED", "IS_GITEA_ENABLED"] if not InstanceConfiguration.objects.filter(key__in=keys).exists(): for key in keys: if key == "IS_GOOGLE_ENABLED": @@ -282,6 +306,48 @@ class Command(BaseCommand): is_encrypted=False, ) self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + if key == "IS_GITEA_ENABLED": + GITEA_HOST, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET = ( + get_configuration_value( + [ + { + "key": "GITEA_HOST", + "default": os.environ.get( + "GITEA_HOST", "" + ), + }, + { + "key": "GITEA_CLIENT_ID", + "default": os.environ.get("GITEA_CLIENT_ID", ""), + }, + { + "key": "GITEA_CLIENT_SECRET", + "default": os.environ.get( + "GITEA_CLIENT_SECRET", "" + ), + }, + ] + ) + ) + if ( + bool(GITEA_HOST) + and bool(GITEA_CLIENT_ID) + and bool(GITEA_CLIENT_SECRET) + ): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key="IS_GITEA_ENABLED", + value=value, + category="AUTHENTICATION", + is_encrypted=False, + ) + self.stdout.write( + self.style.SUCCESS( + f"{key} loaded with value from environment variable." + ) + ) else: for key in keys: self.stdout.write(self.style.WARNING(f"{key} configuration already exists")) diff --git a/apps/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/core/components/account/auth-forms/auth-root.tsx index 86452a3c6..5a54b1906 100644 --- a/apps/space/core/components/account/auth-forms/auth-root.tsx +++ b/apps/space/core/components/account/auth-forms/auth-root.tsx @@ -24,6 +24,7 @@ import GithubLightLogo from "/public/logos/github-black.png"; import GithubDarkLogo from "/public/logos/github-dark.svg"; import GitlabLogo from "/public/logos/gitlab-logo.svg"; import GoogleLogo from "/public/logos/google-logo.svg"; +import GiteaLogo from "/public/logos/gitea-logo.svg"; // local imports import { TermsAndConditions } from "../terms-and-conditions"; import { AuthBanner } from "./auth-banner"; @@ -92,7 +93,12 @@ export const AuthRoot: FC = observer(() => { const isMagicLoginEnabled = config?.is_magic_login_enabled || false; const isEmailPasswordEnabled = config?.is_email_password_enabled || false; const isOAuthEnabled = - (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false; + (config && + (config?.is_google_enabled || + config?.is_github_enabled || + config?.is_gitlab_enabled || + config?.is_gitea_enabled)) || + false; // submit handler- email verification const handleEmailVerification = async (data: IEmailCheckData) => { @@ -189,6 +195,15 @@ export const AuthRoot: FC = observer(() => { }, enabled: config?.is_gitlab_enabled, }, + { + id: "gitea", + text: `${content} with Gitea`, + icon: Gitea Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_gitea_enabled, + }, ]; return ( diff --git a/apps/space/public/logos/gitea-logo.svg b/apps/space/public/logos/gitea-logo.svg new file mode 100644 index 000000000..43291345d --- /dev/null +++ b/apps/space/public/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/logos/gitea-logo.svg b/apps/web/public/logos/gitea-logo.svg new file mode 100644 index 000000000..43291345d --- /dev/null +++ b/apps/web/public/logos/gitea-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/types/src/instance/auth.ts b/packages/types/src/instance/auth.ts index c3049bc45..c65f9ebb8 100644 --- a/packages/types/src/instance/auth.ts +++ b/packages/types/src/instance/auth.ts @@ -13,7 +13,8 @@ export type TInstanceAuthenticationMethodKeys = | "ENABLE_EMAIL_PASSWORD" | "IS_GOOGLE_ENABLED" | "IS_GITHUB_ENABLED" - | "IS_GITLAB_ENABLED"; + | "IS_GITLAB_ENABLED" + | "IS_GITEA_ENABLED"; export type TInstanceGoogleAuthenticationConfigurationKeys = "GOOGLE_CLIENT_ID" | "GOOGLE_CLIENT_SECRET"; @@ -27,10 +28,13 @@ export type TInstanceGitlabAuthenticationConfigurationKeys = | "GITLAB_CLIENT_ID" | "GITLAB_CLIENT_SECRET"; +export type TInstanceGiteaAuthenticationConfigurationKeys = "GITEA_HOST" | "GITEA_CLIENT_ID" | "GITEA_CLIENT_SECRET"; + export type TInstanceAuthenticationConfigurationKeys = | TInstanceGoogleAuthenticationConfigurationKeys | TInstanceGithubAuthenticationConfigurationKeys - | TInstanceGitlabAuthenticationConfigurationKeys; + | TInstanceGitlabAuthenticationConfigurationKeys + | TInstanceGiteaAuthenticationConfigurationKeys; export type TInstanceAuthenticationKeys = TInstanceAuthenticationMethodKeys | TInstanceAuthenticationConfigurationKeys; diff --git a/packages/types/src/instance/base.ts b/packages/types/src/instance/base.ts index 79b1e642f..8f3a0c648 100644 --- a/packages/types/src/instance/base.ts +++ b/packages/types/src/instance/base.ts @@ -42,6 +42,7 @@ export interface IInstanceConfig { is_google_enabled: boolean; is_github_enabled: boolean; is_gitlab_enabled: boolean; + is_gitea_enabled: boolean; is_magic_login_enabled: boolean; is_email_password_enabled: boolean; github_app_name: string | undefined;