snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@@ -0,0 +1,175 @@
# 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 GiteaOAuthProvider(OauthAdapter):
provider = "gitea"
scope = "openid email profile read:user"
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. Never fall back to an unverified
# email — an attacker with a self-hosted Gitea instance could assert any address
# to take over an existing account (GHSA-7j95-vh8g-f365).
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:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"],
error_message="OAUTH_PROVIDER_UNVERIFIED_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",
}
# Always use __get_email() which enforces the verified-email requirement.
# The user object's .email field carries no verification flag, so it cannot
# be trusted directly (GHSA-7j95-vh8g-f365).
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,
},
}
)

View File

@@ -0,0 +1,184 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
from datetime import datetime
from urllib.parse import urlencode
import pytz
import requests
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token"
userinfo_url = "https://api.github.com/user"
org_membership_url = "https://api.github.com/orgs"
provider = "github"
scope = "read:user user:email"
organization_scope = "read:org"
def __init__(self, request, code=None, state=None, callback=None):
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value([
{
"key": "GITHUB_CLIENT_ID",
"default": os.environ.get("GITHUB_CLIENT_ID"),
},
{
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
},
{
"key": "GITHUB_ORGANIZATION_ID",
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
},
])
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_NOT_CONFIGURED"],
error_message="GITHUB_NOT_CONFIGURED",
)
client_id = GITHUB_CLIENT_ID
client_secret = GITHUB_CLIENT_SECRET
self.organization_id = GITHUB_ORGANIZATION_ID
if self.organization_id:
self.scope += f" {self.organization_scope}"
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
url_params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": self.scope,
"state": state,
}
auth_url = f"https://github.com/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 = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": self.code,
"redirect_uri": self.redirect_uri,
}
token_response = self.get_user_token(data=data, headers={"Accept": "application/json"})
super().set_token_data({
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc)
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:
# Github does not provide email in user response
emails_url = "https://api.github.com/user/emails"
emails_response = requests.get(emails_url, headers=headers).json()
# Ensure the response is a list before iterating
if not isinstance(emails_response, list):
self.logger.error("Unexpected response format from GitHub emails API")
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
# Require both primary AND verified — an unverified primary email can be
# exploited to take over an existing account (GHSA-7j95-vh8g-f365).
email = next(
(e["email"] for e in emails_response if e.get("primary") and e.get("verified")),
None,
)
if not email:
self.logger.error("No primary verified email found for GitHub user")
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"],
error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL",
)
return email
except requests.RequestException:
self.logger.warning(
"Error getting email from GitHub",
)
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
def is_user_in_organization(self, github_username):
headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"}
response = requests.get(
f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}",
headers=headers,
)
return response.status_code == 200 # 200 means the user is a member
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",
}
if self.organization_id:
if not self.is_user_in_organization(user_info_response.get("login")):
# Do not log the organization id or user login here:
# the configuration values must not end up in logs
self.logger.warning("User is not in organization")
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"],
error_message="GITHUB_USER_NOT_IN_ORG",
)
email = self.__get_email(headers=headers)
self.logger.debug(
"Email found",
extra={
"email": email,
},
)
super().set_user_data({
"email": email,
"user": {
"provider_id": user_info_response.get("id"),
"email": email,
"avatar": user_info_response.get("avatar_url"),
"first_name": user_info_response.get("name"),
"last_name": user_info_response.get("family_name"),
"is_password_autoset": True,
},
})

View File

@@ -0,0 +1,131 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
from datetime import datetime
from urllib.parse import urlencode
import pytz
# 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 (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
class GitLabOAuthProvider(OauthAdapter):
provider = "gitlab"
scope = "read_user"
def __init__(self, request, code=None, state=None, callback=None):
GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, GITLAB_HOST = get_configuration_value(
[
{
"key": "GITLAB_CLIENT_ID",
"default": os.environ.get("GITLAB_CLIENT_ID"),
},
{
"key": "GITLAB_CLIENT_SECRET",
"default": os.environ.get("GITLAB_CLIENT_SECRET"),
},
{
"key": "GITLAB_HOST",
"default": os.environ.get("GITLAB_HOST", "https://gitlab.com"),
},
]
)
self.host = GITLAB_HOST
self.token_url = f"{self.host}/oauth/token"
self.userinfo_url = f"{self.host}/api/v4/user"
if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET and GITLAB_HOST):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"],
error_message="GITLAB_NOT_CONFIGURED",
)
client_id = GITLAB_CLIENT_ID
client_secret = GITLAB_CLIENT_SECRET
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/gitlab/callback/"""
url_params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": self.scope,
"state": state,
}
auth_url = f"{self.host}/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 = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": self.code,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
}
token_response = self.get_user_token(data=data, headers={"Accept": "application/json"})
super().set_token_data(
{
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.fromtimestamp(
token_response.get("created_at") + token_response.get("expires_in"),
tz=pytz.utc,
)
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 set_user_data(self):
user_info_response = self.get_user_response()
# confirmed_at is null/absent for unverified GitLab accounts. Reject them to
# prevent ATO via self-hosted GitLab with unverified emails (GHSA-7j95-vh8g-f365).
if not user_info_response.get("confirmed_at"):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"],
error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL",
)
email = user_info_response.get("email")
super().set_user_data(
{
"email": email,
"user": {
"provider_id": user_info_response.get("id"),
"email": email,
"avatar": user_info_response.get("avatar_url"),
"first_name": user_info_response.get("name"),
"last_name": user_info_response.get("family_name"),
"is_password_autoset": True,
},
}
)

View File

@@ -0,0 +1,123 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
from datetime import datetime
from urllib.parse import urlencode
import pytz
# 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 GoogleOAuthProvider(OauthAdapter):
token_url = "https://oauth2.googleapis.com/token"
userinfo_url = "https://www.googleapis.com/oauth2/v2/userinfo"
scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
provider = "google"
def __init__(self, request, code=None, state=None, callback=None):
(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) = get_configuration_value(
[
{
"key": "GOOGLE_CLIENT_ID",
"default": os.environ.get("GOOGLE_CLIENT_ID"),
},
{
"key": "GOOGLE_CLIENT_SECRET",
"default": os.environ.get("GOOGLE_CLIENT_SECRET"),
},
]
)
if not (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GOOGLE_NOT_CONFIGURED"],
error_message="GOOGLE_NOT_CONFIGURED",
)
client_id = GOOGLE_CLIENT_ID
client_secret = GOOGLE_CLIENT_SECRET
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/google/callback/"""
url_params = {
"client_id": client_id,
"scope": self.scope,
"redirect_uri": redirect_uri,
"response_type": "code",
"access_type": "offline",
"prompt": "consent",
"state": state,
}
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{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",
}
token_response = self.get_user_token(data=data)
super().set_token_data(
{
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc)
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 set_user_data(self):
user_info_response = self.get_user_response()
# Reject unverified emails — an attacker-controlled provider could otherwise assert
# any email to match an existing account (GHSA-7j95-vh8g-f365). Fail closed: treat
# an absent verified_email claim the same as verified_email=false.
if user_info_response.get("verified_email") is not True:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["OAUTH_PROVIDER_UNVERIFIED_EMAIL"],
error_message="OAUTH_PROVIDER_UNVERIFIED_EMAIL",
)
user_data = {
"email": user_info_response.get("email"),
"user": {
"avatar": user_info_response.get("picture"),
"first_name": user_info_response.get("given_name"),
"last_name": user_info_response.get("family_name"),
"provider_id": user_info_response.get("id"),
"is_password_autoset": True,
},
}
super().set_user_data(user_data)