diff --git a/apps/admin/app/(all)/(dashboard)/authentication/oidc-free/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/oidc-free/form.tsx new file mode 100644 index 00000000000..906ed1a7e8e --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/oidc-free/form.tsx @@ -0,0 +1,272 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +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 { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceOidcFreeAuthenticationConfigurationKeys } from "@plane/types"; +// 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 { TControllerSwitchFormField } from "@/components/common/controller-switch"; +import { ControllerSwitch } from "@/components/common/controller-switch"; +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 OidcFreeConfigFormValues = Record; + +export function InstanceOidcFreeConfigForm(props: 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: { + OIDC_FREE_CLIENT_ID: config["OIDC_FREE_CLIENT_ID"], + OIDC_FREE_CLIENT_SECRET: config["OIDC_FREE_CLIENT_SECRET"], + OIDC_FREE_HOST: config["OIDC_FREE_HOST"], + OIDC_FREE_SCOPE: config["OIDC_FREE_SCOPE"], + OIDC_FREE_USERINFO_URL: config["OIDC_FREE_USERINFO_URL"], + OIDC_FREE_TOKEN_URL: config["OIDC_FREE_TOKEN_URL"], + OIDC_FREE_CALLBACK_URI: config["OIDC_FREE_CALLBACK_URI"], + OIDC_FREE_AUTH_URI: config["OIDC_FREE_AUTH_URI"], + ENABLE_OIDC_FREE_SYNC: config["ENABLE_OIDC_FREE_SYNC"] || "0", + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const OIDC_FREE_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "OIDC_FREE_HOST", + type: "text", + label: "Oidc Free Host", + description: ( + <>Use the URL of your Oidc Free instance. + ), + placeholder: "https://", + error: Boolean(errors.OIDC_FREE_HOST), + required: true, + }, + { + key: "OIDC_FREE_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + blabla + + ), + placeholder: "70a44354520df8bd9bcd", + error: Boolean(errors.OIDC_FREE_CLIENT_ID), + required: true, + }, + { + key: "OIDC_FREE_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + blabla + + ), + placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb", + error: Boolean(errors.OIDC_FREE_CLIENT_SECRET), + required: true, + }, + + { + key: "OIDC_FREE_SCOPE", + type: "text", + label: "", + description: ( + <> + blabla + + ), + placeholder: "placeholder", + error: Boolean(errors.OIDC_FREE_SCOPE), + required: true + }, + { + key: "OIDC_FREE_USERINFO_URL", + type: "text", + label: "", + description: ( + <> + blabla + + ), + placeholder: "placeholder", + error: Boolean(errors.OIDC_FREE_USERINFO_URL), + required: true + }, + { + key: "OIDC_FREE_TOKEN_URL", + type: "text", + label: "", + description: ( + <> + blabla + + ), + placeholder: "placeholder", + error: Boolean(errors.OIDC_FREE_TOKEN_URL), + required: true + }, + { + key: "OIDC_FREE_CALLBACK_URI", + type: "text", + label: "", + description: ( + <> + blabla + + ), + placeholder: "placeholder", + error: Boolean(errors.OIDC_FREE_CALLBACK_URI), + required: true + }, + { + key: "OIDC_FREE_AUTH_URI", + type: "text", + label: "", + description: ( + <> + blabla + + ), + placeholder: "placeholder", + error: Boolean(errors.OIDC_FREE_AUTH_URI), + required: true + }, + ]; + + const OIDC_FREE_FORM_SWITCH_FIELD: TControllerSwitchFormField = { + name: "ENABLE_OIDC_FREE_SYNC", + label: "Oidc Free", + }; + + const OIDC_FREE_SERVICE_FIELD: TCopyField[] = [ + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/oidc-free/callback/`, + description: ( + <> + We will auto-generate this. + + ), + }, + ]; + + const onSubmit = async (formData: OidcFreeConfigFormValues) => { + const payload: Partial = { ...formData }; + + try { + const response = await updateInstanceConfigurations(payload); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Oidc Free authentication is configured. You should test it now.", + }); + reset({ + OIDC_FREE_CLIENT_ID: response.find((item) => item.key === "OIDC_FREE_CLIENT_ID")?.value, + OIDC_FREE_CLIENT_SECRET: response.find((item) => item.key === "OIDC_FREE_CLIENT_SECRET")?.value, + OIDC_FREE_HOST: response.find((item) => item.key === "OIDC_FREE_HOST")?.value, + OIDC_FREE_SCOPE: response.find((item) => item.key === "OIDC_FREE_SCOPE")?.value, + OIDC_FREE_USERINFO_URL: response.find((item) => item.key === "OIDC_FREE_USERINFO_URL")?.value, + OIDC_FREE_TOKEN_URL: response.find((item) => item.key === "OIDC_FREE_TOKEN_URL")?.value, + OIDC_FREE_CALLBACK_URI: response.find((item) => item.key === "OIDC_FREE_CALLBACK_URI")?.value, + OIDC_FREE_AUTH_URI: response.find((item) => item.key === "OIDC_FREE_AUTH_URI")?.value, + }); + } catch (err) { + console.error(err); + } + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Oidc Free-provided details for Plane
+ {OIDC_FREE_FORM_FIELDS.map((field) => ( + + ))} + +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for Oidc Free
+ {OIDC_FREE_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/oidc-free/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/oidc-free/page.tsx new file mode 100644 index 00000000000..cd26448c7c3 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/oidc-free/page.tsx @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane internal packages +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; +// assets +import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { PageWrapper } from "@/components/common/page-wrapper"; +// hooks +import { useInstance } from "@/hooks/store"; +// types +import type { Route } from "./+types/page"; +// local +import { InstanceOidcFreeConfigForm } from "./form"; + +const InstanceOidcFreeAuthenticationPage = observer(function InstanceOidcFreeAuthenticationPage() { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableOidcFreeConfig = formattedConfig?.IS_OIDC_FREE_ENABLED ?? ""; + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_OIDC_FREE_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration", + success: { + title: "Configuration saved", + message: () => `Oidc Free 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 isOidcFreeEnabled = enableOidcFreeConfig === "1"; + + return ( + } + config={ + { + updateConfig("IS_OIDC_FREE_ENABLED", isOidcFreeEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> + } + > + {formattedConfig ? ( + + ) : ( + + + + + + + + )} + + ); +}); +export const meta: Route.MetaFunction = () => [{ title: "Oidc Free Authentication - God Mode" }]; + +export default InstanceOidcFreeAuthenticationPage; diff --git a/apps/admin/app/routes.ts b/apps/admin/app/routes.ts index 184bed205a7..8657cfa528b 100644 --- a/apps/admin/app/routes.ts +++ b/apps/admin/app/routes.ts @@ -19,6 +19,7 @@ export default [ route("authentication/gitlab", "./(all)/(dashboard)/authentication/gitlab/page.tsx"), route("authentication/google", "./(all)/(dashboard)/authentication/google/page.tsx"), route("authentication/gitea", "./(all)/(dashboard)/authentication/gitea/page.tsx"), + route("authentication/oidc-free", "./(all)/(dashboard)/authentication/oidc-free/page.tsx"), route("ai", "./(all)/(dashboard)/ai/page.tsx"), route("image", "./(all)/(dashboard)/image/page.tsx"), ]), diff --git a/apps/admin/components/authentication/oidc-free-config.tsx b/apps/admin/components/authentication/oidc-free-config.tsx new file mode 100644 index 00000000000..39a25356671 --- /dev/null +++ b/apps/admin/components/authentication/oidc-free-config.tsx @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// plane internal packages +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } 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 OidcFreeConfiguration = observer(function OidcFreeConfiguration(props: Props) { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const OidcFreeConfig = formattedConfig?.IS_OIDC_FREE_ENABLED ?? ""; + const OidcFreeConfigured = + [ + !!formattedConfig?.OIDC_FREE_CLIENT_ID, + !!formattedConfig?.OIDC_FREE_CLIENT_SECRET, + !!formattedConfig?.OIDC_FREE_HOST, + !!formattedConfig?.OIDC_FREE_SCOPE, + !!formattedConfig?.OIDC_FREE_USERINFO_URL, + !!formattedConfig?.OIDC_FREE_TOKEN_URL, + !!formattedConfig?.OIDC_FREE_CALLBACK_URI, + !!formattedConfig?.OIDC_FREE_AUTH_URI, + ].reduce((acc, curr) => acc && curr, true); + + return ( + <> + {OidcFreeConfigured ? ( +
+ + Edit + + { + Boolean(parseInt(OidcFreeConfig)) === true + ? updateConfig("IS_OIDC_FREE_ENABLED", "0") + : updateConfig("IS_OIDC_FREE_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/apps/admin/hooks/oauth/core.tsx b/apps/admin/hooks/oauth/core.tsx index 9e6914e41cc..06fb5292b9a 100644 --- a/apps/admin/hooks/oauth/core.tsx +++ b/apps/admin/hooks/oauth/core.tsx @@ -20,6 +20,7 @@ import googleLogo from "@/app/assets/logos/google-logo.svg?url"; // components import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; import { GiteaConfiguration } from "@/components/authentication/gitea-config"; +import { OidcFreeConfiguration } from "@/components/authentication/oidc-free-config"; import { GithubConfiguration } from "@/components/authentication/github-config"; import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; import { GoogleConfiguration } from "@/components/authentication/google-config"; @@ -89,4 +90,12 @@ export const getCoreAuthenticationModesMap: ( config: , enabledConfigKey: "IS_GITEA_ENABLED", }, + "oidc-free": { + key: "oidc-free", + name: "Oidc Free", + description: "Allow members to log in or sign up to plane with any OIDC provider.", + icon: Gitea Logo, + config: , + enabledConfigKey: "IS_OIDC_FREE_ENABLED", + }, }); diff --git a/apps/admin/hooks/oauth/index.ts b/apps/admin/hooks/oauth/index.ts index 74c11e33fcd..3e0221f6058 100644 --- a/apps/admin/hooks/oauth/index.ts +++ b/apps/admin/hooks/oauth/index.ts @@ -19,6 +19,7 @@ export const useAuthenticationModes = (props: TGetAuthenticationModeProps): TIns authenticationModes["github"], authenticationModes["gitlab"], authenticationModes["gitea"], + authenticationModes["oidc-free"], ]; return availableAuthenticationModes; diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py index 1b906c4a8d4..f60a229ccfc 100644 --- a/apps/api/plane/authentication/adapter/base.py +++ b/apps/api/plane/authentication/adapter/base.py @@ -129,6 +129,7 @@ def check_sync_enabled(self): "github": "ENABLE_GITHUB_SYNC", "gitlab": "ENABLE_GITLAB_SYNC", "gitea": "ENABLE_GITEA_SYNC", + "oidc-free": "ENABLE_OIDC_FREE_SYNC", } config_key = provider_config_map.get(self.provider) if config_key: diff --git a/apps/api/plane/authentication/provider/oauth/gitea.py b/apps/api/plane/authentication/provider/oauth/gitea.py index 8c0c3a5db51..ccb1781d65e 100644 --- a/apps/api/plane/authentication/provider/oauth/gitea.py +++ b/apps/api/plane/authentication/provider/oauth/gitea.py @@ -61,7 +61,8 @@ def __init__(self, request, code=None, state=None, callback=None): 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/" + server_host = request.get_host() + (":" + request.get_port() if request.get_port() != 80 else "") + redirect_uri = f"{'https' if request.is_secure() else 'http'}://{server_host}/auth/gitea/callback/" url_params = { "client_id": client_id, "scope": self.scope, diff --git a/apps/api/plane/authentication/provider/oauth/oidc_free.py b/apps/api/plane/authentication/provider/oauth/oidc_free.py new file mode 100644 index 00000000000..03820c62438 --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/oidc_free.py @@ -0,0 +1,196 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +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 OidcFreeOAuthProvider(OauthAdapter): + provider = "oidc-free" + + def __read_option_from_env(self, option: str, default: str | None = None): + env_default = os.environ.get(option) + return { + "key": option, + "default": env_default if not default else env_default or default, + } + + def __init__(self, request, code=None, state=None, callback=None): + ( + OIDC_FREE_CLIENT_ID, + OIDC_FREE_CLIENT_SECRET, + OIDC_FREE_HOST, + OIDC_FREE_SCOPE, + OIDC_FREE_USERINFO_URL, + OIDC_FREE_TOKEN_URL, + OIDC_FREE_CALLBACK_URI, + OIDC_FREE_AUTH_URI, + ) = get_configuration_value( + [ + self.__read_option_from_env("OIDC_FREE_CLIENT_ID"), + self.__read_option_from_env("OIDC_FREE_CLIENT_SECRET"), + self.__read_option_from_env("OIDC_FREE_HOST"), + self.__read_option_from_env("OIDC_FREE_SCOPE", "openid email profile"), + self.__read_option_from_env("OIDC_FREE_USERINFO_URL"), + self.__read_option_from_env("OIDC_FREE_TOKEN_URL"), + self.__read_option_from_env("OIDC_FREE_CALLBACK_URI"), + self.__read_option_from_env("OIDC_FREE_AUTH_URI"), + ] + ) + + if any(v is None for v in [ + OIDC_FREE_CLIENT_ID, + OIDC_FREE_CLIENT_SECRET, + OIDC_FREE_HOST, + OIDC_FREE_SCOPE, + OIDC_FREE_USERINFO_URL, + OIDC_FREE_TOKEN_URL, + OIDC_FREE_CALLBACK_URI, + OIDC_FREE_AUTH_URI + ]): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["OIDC_FREE_NOT_CONFIGURED"], + error_message="OIDC_FREE_NOT_CONFIGURED", + ) + + # Enforce scheme and normalize trailing slash(es) + parsed = urlparse(OIDC_FREE_HOST) + if not parsed.scheme or parsed.scheme not in ("https", "http"): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["OIDC_FREE_NOT_CONFIGURED"], + error_message="OIDC_FREE_NOT_CONFIGURED", # avoid leaking details to query params + ) + OIDC_FREE_HOST = OIDC_FREE_HOST.rstrip("/") + + # Set URLs based on the host + self.token_url = f"{OIDC_FREE_HOST}/{OIDC_FREE_TOKEN_URL}" + self.userinfo_url = f"{OIDC_FREE_HOST}/{OIDC_FREE_USERINFO_URL}" + + scope = OIDC_FREE_SCOPE + client_id = OIDC_FREE_CLIENT_ID + client_secret = OIDC_FREE_CLIENT_SECRET + + server_host = request.get_host() + (":" + request.get_port() if request.get_port() != 80 else "") + redirect_uri = f"{'https' if request.is_secure() else 'http'}://{server_host}/{OIDC_FREE_CALLBACK_URI.lstrip('/')}" + url_params = { + "client_id": client_id, + "scope": scope, + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + } + auth_url = f"{OIDC_FREE_HOST}/{OIDC_FREE_AUTH_URI.lstrip('/')}?{urlencode(url_params)}" + + super().__init__( + request, + self.provider, + client_id, + 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["OIDC_FREE_OAUTH_PROVIDER_ERROR"], + error_message="OIDC_FREE_OAUTH_PROVIDER_ERROR: Failed to fetch emails", + ) + emails_response = response.json() + + if not emails_response: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["OIDC_FREE_OAUTH_PROVIDER_ERROR"], + error_message="OIDC_FREE_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["OIDC_FREE_OAUTH_PROVIDER_ERROR"], + error_message="OIDC_FREE_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("sub")), + "email": email, + # have to set empty string, to prevent violating non-null constraint + "avatar": user_info_response.get("avatar_url") or "", + "first_name": user_info_response.get("given_name") or user_info_response.get("login"), + "last_name": user_info_response.get("family_name"), + "is_password_autoset": True, + }, + } + ) diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index 4bec07db00b..91feb629041 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -44,6 +44,11 @@ GiteaOauthInitiateEndpoint, GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint, + # OIDC Free + OidcFreeCallbackEndpoint, + OidcFreeOauthInitiateEndpoint, + # OidcFreeCallbackSpaceEndpoint, + # OidcFreeOauthInitiateSpaceEndpoint, ) urlpatterns = [ @@ -150,4 +155,17 @@ GiteaCallbackSpaceEndpoint.as_view(), name="space-gitea-callback", ), + ## Oidc Free Oauth + path("oidc-free/", OidcFreeOauthInitiateEndpoint.as_view(), name="oidc-free-initiate"), + path("oidc-free/callback/", OidcFreeCallbackEndpoint.as_view(), name="oidc-free-callback"), + # path( + # "spaces/oidc-free/", + # OidcFreeOauthInitiateSpaceEndpoint.as_view(), + # name="space-oidc-free-initiate", + # ), + # path( + # "spaces/oidc-free/callback/", + # OidcFreeCallbackSpaceEndpoint.as_view(), + # name="space-oidc-free-callback", + # ), ] diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index a9c816ae9ea..9dac5ed2802 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -10,6 +10,7 @@ from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint +from .app.oidc_free import OidcFreeCallbackEndpoint, OidcFreeOauthInitiateEndpoint from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint diff --git a/apps/api/plane/authentication/views/app/oidc_free.py b/apps/api/plane/authentication/views/app/oidc_free.py new file mode 100644 index 00000000000..89dd56aa658 --- /dev/null +++ b/apps/api/plane/authentication/views/app/oidc_free.py @@ -0,0 +1,107 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +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.oidc_free import OidcFreeOAuthProvider +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 OidcFreeOauthInitiateEndpoint(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 = OidcFreeOAuthProvider(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 OidcFreeCallbackEndpoint(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["OIDC_FREE_OAUTH_PROVIDER_ERROR"], + error_message="OIDC_FREE_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["OIDC_FREE_OAUTH_PROVIDER_ERROR"], + error_message="OIDC_FREE_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 = OidcFreeOAuthProvider(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/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index 29c2521abd8..1619e22edb2 100644 --- a/apps/api/plane/license/api/views/instance.py +++ b/apps/api/plane/license/api/views/instance.py @@ -55,6 +55,7 @@ def get(self, request): GITHUB_APP_NAME, IS_GITLAB_ENABLED, IS_GITEA_ENABLED, + IS_OIDC_FREE_ENABLED, EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN, ENABLE_EMAIL_PASSWORD, @@ -93,7 +94,14 @@ def get(self, request): "key": "IS_GITEA_ENABLED", "default": os.environ.get("IS_GITEA_ENABLED", "0"), }, - {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, + { + "key": "IS_OIDC_FREE_ENABLED", + "default": os.environ.get("IS_OIDC_FREE_ENABLED", "0"), + }, + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST", "") + }, { "key": "ENABLE_MAGIC_LINK_LOGIN", "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), @@ -133,6 +141,7 @@ def get(self, request): 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_oidc_free_enabled"] = IS_OIDC_FREE_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 43026a45543..f072ba8b6bd 100644 --- a/apps/api/plane/license/management/commands/configure_instance.py +++ b/apps/api/plane/license/management/commands/configure_instance.py @@ -40,7 +40,7 @@ def handle(self, *args, **options): else: self.stdout.write(self.style.WARNING(f"{obj.key} configuration already exists")) - keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED", "IS_GITEA_ENABLED"] + keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED", "IS_GITEA_ENABLED", "IS_OIDC_FREE_ENABLED"] if not InstanceConfiguration.objects.filter(key__in=keys).exists(): for key in keys: if key == "IS_GOOGLE_ENABLED": @@ -147,6 +147,73 @@ def handle(self, *args, **options): is_encrypted=False, ) self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + if key == "IS_OIDC_FREE_ENABLED": + ( + OIDC_FREE_HOST, + OIDC_FREE_CLIENT_ID, + OIDC_FREE_CLIENT_SECRET, + OIDC_FREE_HOST, + OIDC_FREE_SCOPE, + OIDC_FREE_USERINFO_URL, + OIDC_FREE_TOKEN_URL, + OIDC_FREE_CALLBACK_URI, + OIDC_FREE_AUTH_URI + ) = get_configuration_value( + [ + { + "key": "OIDC_FREE_CLIENT_ID", + "default": os.environ.get("OIDC_FREE_CLIENT_ID", ""), + }, + { + "key": "OIDC_FREE_CLIENT_SECRET", + "default": os.environ.get("OIDC_FREE_CLIENT_SECRET", ""), + }, + { + "key": "OIDC_FREE_HOST", + "default": os.environ.get("OIDC_FREE_HOST", ""), + }, + { + "key": "OIDC_FREE_SCOPE", + "default": os.environ.get("OIDC_FREE_SCOPE", ""), + }, + { + "key": "OIDC_FREE_USERINFO_URL", + "default": os.environ.get("OIDC_FREE_USERINFO_URL", ""), + }, + { + "key": "OIDC_FREE_TOKEN_URL", + "default": os.environ.get("OIDC_FREE_TOKEN_URL", ""), + }, + { + "key": "OIDC_FREE_CALLBACK_URI", + "default": os.environ.get("OIDC_FREE_CALLBACK_URI", ""), + }, + { + "key": "OIDC_FREE_AUTH_URI", + "default": os.environ.get("OIDC_FREE_AUTH_URI", ""), + }, + ] + ) + if all(bool(v) for v in [ + OIDC_FREE_CLIENT_ID, + OIDC_FREE_CLIENT_SECRET, + OIDC_FREE_HOST, + OIDC_FREE_SCOPE, + OIDC_FREE_USERINFO_URL, + OIDC_FREE_TOKEN_URL, + OIDC_FREE_CALLBACK_URI, + OIDC_FREE_AUTH_URI + ]): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key="IS_OIDC_FREE_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/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 65b5d7b9f82..91ca1551645 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -535,3 +535,8 @@ def _retention_days(env_var, default): REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema" INSTALLED_APPS.append("drf_spectacular") from .openapi import SPECTACULAR_SETTINGS # noqa: F401 + +USE_X_FORWARDED_HOST = int(os.environ.get("USE_X_FORWARDED_HOST", 0)) == 1 +USE_X_FORWARDED_PORT = int(os.environ.get("USE_X_FORWARDED_PORT", 0)) == 1 +if os.environ.get("SECURE_PROXY_SSL_HEADER", 0): + SECURE_PROXY_SSL_HEADER = (os.environ.get("SECURE_PROXY_SSL_HEADER", 0), "https") diff --git a/apps/api/plane/utils/instance_config_variables/core.py b/apps/api/plane/utils/instance_config_variables/core.py index 6eebf0b3adb..54e7af8aa84 100644 --- a/apps/api/plane/utils/instance_config_variables/core.py +++ b/apps/api/plane/utils/instance_config_variables/core.py @@ -144,6 +144,75 @@ }, ] +oidc_free_config_variables = [ + { + "key": "IS_OIDC_FREE_ENABLED", + "value": os.environ.get("IS_OIDC_FREE_ENABLED", "0"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "OIDC_FREE_HOST", + "value": os.environ.get("OIDC_FREE_HOST"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "OIDC_FREE_CLIENT_ID", + "value": os.environ.get("OIDC_FREE_CLIENT_ID"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "OIDC_FREE_CLIENT_SECRET", + "value": os.environ.get("OIDC_FREE_CLIENT_SECRET"), + "category": "OIDC_FREE", + "is_encrypted": True, + }, + { + "key": "OIDC_FREE_CLIENT_ID", + "value": os.environ.get("OIDC_FREE_CLIENT_ID"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "OIDC_FREE_SCOPE", + "value": os.environ.get("OIDC_FREE_SCOPE"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "OIDC_FREE_USERINFO_URL", + "value": os.environ.get("OIDC_FREE_USERINFO_URL"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "OIDC_FREE_TOKEN_URL", + "value": os.environ.get("OIDC_FREE_TOKEN_URL"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "OIDC_FREE_CALLBACK_URI", + "value": os.environ.get("OIDC_FREE_CALLBACK_URI"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "OIDC_FREE_AUTH_URI", + "value": os.environ.get("OIDC_FREE_AUTH_URI"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, + { + "key": "ENABLE_OIDC_FREE_SYNC", + "value": os.environ.get("ENABLE_OIDC_FREE_SYNC", "0"), + "category": "OIDC_FREE", + "is_encrypted": False, + }, +] + smtp_config_variables = [ { "key": "ENABLE_SMTP", @@ -239,6 +308,7 @@ *github_config_variables, *gitlab_config_variables, *gitea_config_variables, + *oidc_free_config_variables, *smtp_config_variables, *llm_config_variables, *unsplash_config_variables, diff --git a/apps/space/hooks/oauth/core.tsx b/apps/space/hooks/oauth/core.tsx index 63a18cc2e59..b7d30c1a15f 100644 --- a/apps/space/hooks/oauth/core.tsx +++ b/apps/space/hooks/oauth/core.tsx @@ -33,7 +33,8 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled || - config?.is_gitea_enabled)) || + config?.is_gitea_enabled || + config?.is_oidc_free_enabled)) || false; const oAuthOptions: TOAuthOption[] = [ { @@ -79,6 +80,15 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { }, enabled: config?.is_gitea_enabled, }, + { + id: "oidc-free", + text: `${oauthActionText} with Oidc Free`, + icon: Gitea Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/oidc-free/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_oidc_free_enabled, + }, ]; return { diff --git a/apps/web/core/hooks/oauth/core.tsx b/apps/web/core/hooks/oauth/core.tsx index 1614883fe86..1ba2df19d61 100644 --- a/apps/web/core/hooks/oauth/core.tsx +++ b/apps/web/core/hooks/oauth/core.tsx @@ -33,7 +33,8 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled || - config?.is_gitea_enabled)) || + config?.is_gitea_enabled || + config?.is_oidc_free_enabled)) || false; const oAuthOptions: TOAuthOption[] = [ { @@ -79,6 +80,15 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { }, enabled: config?.is_gitea_enabled, }, + { + id: "oidc-free", + text: `${oauthActionText} with Oidc Free`, + icon: Gitea Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/oidc-free/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_oidc_free_enabled, + }, ]; return { diff --git a/packages/types/src/instance/auth.ts b/packages/types/src/instance/auth.ts index f3566b291f7..30a630605b3 100644 --- a/packages/types/src/instance/auth.ts +++ b/packages/types/src/instance/auth.ts @@ -31,7 +31,8 @@ export type TInstanceAuthenticationMethodKeys = | "IS_GOOGLE_ENABLED" | "IS_GITHUB_ENABLED" | "IS_GITLAB_ENABLED" - | "IS_GITEA_ENABLED"; + | "IS_GITEA_ENABLED" + | "IS_OIDC_FREE_ENABLED"; export type TInstanceGoogleAuthenticationConfigurationKeys = | "GOOGLE_CLIENT_ID" @@ -56,11 +57,23 @@ export type TInstanceGiteaAuthenticationConfigurationKeys = | "GITEA_CLIENT_SECRET" | "ENABLE_GITEA_SYNC"; +export type TInstanceOidcFreeAuthenticationConfigurationKeys = + | "OIDC_FREE_CLIENT_ID" + | "OIDC_FREE_CLIENT_SECRET" + | "OIDC_FREE_HOST" + | "OIDC_FREE_SCOPE" + | "OIDC_FREE_USERINFO_URL" + | "OIDC_FREE_TOKEN_URL" + | "OIDC_FREE_CALLBACK_URI" + | "OIDC_FREE_AUTH_URI" + | "ENABLE_OIDC_FREE_SYNC"; + export type TInstanceAuthenticationConfigurationKeys = | TInstanceGoogleAuthenticationConfigurationKeys | TInstanceGithubAuthenticationConfigurationKeys | TInstanceGitlabAuthenticationConfigurationKeys - | TInstanceGiteaAuthenticationConfigurationKeys; + | TInstanceGiteaAuthenticationConfigurationKeys + | TInstanceOidcFreeAuthenticationConfigurationKeys; export type TInstanceAuthenticationKeys = TInstanceAuthenticationMethodKeys | TInstanceAuthenticationConfigurationKeys; diff --git a/packages/types/src/instance/base.ts b/packages/types/src/instance/base.ts index a93baef524f..7accabfd301 100644 --- a/packages/types/src/instance/base.ts +++ b/packages/types/src/instance/base.ts @@ -51,6 +51,7 @@ export interface IInstanceConfig { is_github_enabled: boolean; is_gitlab_enabled: boolean; is_gitea_enabled: boolean; + is_oidc_free_enabled: boolean; is_magic_login_enabled: boolean; is_email_password_enabled: boolean; github_app_name: string | undefined;