diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index 6d789311020..a8207783bc6 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -45,10 +45,12 @@ "GITHUB_USER_NOT_IN_ORG": 5122, "GITLAB_NOT_CONFIGURED": 5111, "GITEA_NOT_CONFIGURED": 5112, + "MICROSOFT_NOT_CONFIGURED": 5113, "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GITHUB_OAUTH_PROVIDER_ERROR": 5120, "GITLAB_OAUTH_PROVIDER_ERROR": 5121, "GITEA_OAUTH_PROVIDER_ERROR": 5123, + "MICROSOFT_OAUTH_PROVIDER_ERROR": 5126, "OAUTH_PROVIDER_UNVERIFIED_EMAIL": 5124, # Reset Password "INVALID_PASSWORD_TOKEN": 5125, diff --git a/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py index afb1a31325d..a88d8a1c109 100644 --- a/apps/api/plane/authentication/adapter/oauth.py +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -55,6 +55,8 @@ def authentication_error_code(self): return "GITLAB_OAUTH_PROVIDER_ERROR" elif self.provider == "gitea": return "GITEA_OAUTH_PROVIDER_ERROR" + elif self.provider == "microsoft": + return "MICROSOFT_OAUTH_PROVIDER_ERROR" else: return "OAUTH_NOT_CONFIGURED" @@ -78,8 +80,11 @@ def get_user_token(self, data, headers=None): response = requests.post(self.get_token_url(), data=data, headers=headers) response.raise_for_status() return response.json() - except requests.RequestException: - self.logger.warning("Error getting user token") + except requests.RequestException as e: + if hasattr(e, 'response') and e.response is not None: + self.logger.warning(f"Error getting user token: {e.response.status_code} {e.response.text[:500]}") + else: + self.logger.warning(f"Error getting user token: {e}") code = self.authentication_error_code() raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code)) diff --git a/apps/api/plane/authentication/provider/oauth/microsoft.py b/apps/api/plane/authentication/provider/oauth/microsoft.py new file mode 100644 index 00000000000..1dca76a9b79 --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/microsoft.py @@ -0,0 +1,90 @@ +# 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 +import pytz +import requests + +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 MicrosoftOAuthProvider(OauthAdapter): + userinfo_url = "https://graph.microsoft.com/v1.0/me" + scope = "openid email profile https://graph.microsoft.com/User.Read" + provider = "microsoft" + + def __init__(self, request, code=None, state=None, callback=None): + (MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, MICROSOFT_TENANT_ID) = get_configuration_value( + [ + {"key": "MICROSOFT_CLIENT_ID", "default": os.environ.get("MICROSOFT_CLIENT_ID")}, + {"key": "MICROSOFT_CLIENT_SECRET", "default": os.environ.get("MICROSOFT_CLIENT_SECRET")}, + {"key": "MICROSOFT_TENANT_ID", "default": os.environ.get("MICROSOFT_TENANT_ID")}, + ] + ) + if not (MICROSOFT_CLIENT_ID and MICROSOFT_CLIENT_SECRET): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MICROSOFT_NOT_CONFIGURED"], + error_message="MICROSOFT_NOT_CONFIGURED", + ) + tenant = MICROSOFT_TENANT_ID or "common" + self.token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + self.auth_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize" + redirect_uri = f"{'https' if request.is_secure() else 'http'}://{request.get_host()}/auth/microsoft/callback/" + from urllib.parse import urlencode + url_params = { + "client_id": MICROSOFT_CLIENT_ID, + "scope": self.scope, + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + } + auth_url = f"{self.auth_url}?{urlencode(url_params)}" + super().__init__( + request, self.provider, MICROSOFT_CLIENT_ID, self.scope, redirect_uri, + auth_url, self.token_url, self.userinfo_url, + client_secret=MICROSOFT_CLIENT_SECRET, code=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", + "scope": self.scope, + } + 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": None, + "id_token": token_response.get("id_token", ""), + }) + + def set_user_data(self): + headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"} + user_info_response = requests.get(self.userinfo_url, headers=headers).json() + email = user_info_response.get("mail") or user_info_response.get("userPrincipalName") + user_data = { + "email": email, + "user": { + "avatar": "", + "first_name": user_info_response.get("givenName", ""), + "last_name": user_info_response.get("surname", ""), + "provider_id": user_info_response.get("id"), + "is_password_autoset": True, + }, + } + super().set_user_data(user_data) diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index 4bec07db00b..35432c5ecd6 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -18,6 +18,8 @@ GitHubOauthInitiateEndpoint, GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint, + MicrosoftCallbackEndpoint, + MicrosoftOauthInitiateEndpoint, MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint, @@ -34,6 +36,8 @@ GitHubOauthInitiateSpaceEndpoint, GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint, + MicrosoftCallbackSpaceEndpoint, + MicrosoftOauthInitiateSpaceEndpoint, MagicGenerateSpaceEndpoint, MagicSignInSpaceEndpoint, MagicSignUpSpaceEndpoint, @@ -79,6 +83,19 @@ ## Google Oauth path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"), path("google/callback/", GoogleCallbackEndpoint.as_view(), name="google-callback"), + ## Microsoft Oauth + path("microsoft/", MicrosoftOauthInitiateEndpoint.as_view(), name="microsoft-initiate"), + path("microsoft/callback/", MicrosoftCallbackEndpoint.as_view(), name="microsoft-callback"), + path( + "spaces/microsoft/", + MicrosoftOauthInitiateSpaceEndpoint.as_view(), + name="space-microsoft-initiate", + ), + path( + "spaces/microsoft/callback/", + MicrosoftCallbackSpaceEndpoint.as_view(), + name="space-microsoft-callback", + ), path( "spaces/google/", GoogleOauthInitiateSpaceEndpoint.as_view(), diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index a9c816ae9ea..4c607b6c0a7 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -11,6 +11,7 @@ from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint +from .app.microsoft import MicrosoftCallbackEndpoint, MicrosoftOauthInitiateEndpoint from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint from .app.signout import SignOutAuthEndpoint @@ -25,6 +26,7 @@ from .space.gitea import GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint +from .space.microsoft import MicrosoftCallbackSpaceEndpoint, MicrosoftOauthInitiateSpaceEndpoint from .space.magic import ( MagicGenerateSpaceEndpoint, diff --git a/apps/api/plane/authentication/views/app/microsoft.py b/apps/api/plane/authentication/views/app/microsoft.py new file mode 100644 index 00000000000..dce79970161 --- /dev/null +++ b/apps/api/plane/authentication/views/app/microsoft.py @@ -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 uuid +from django.http import HttpResponseRedirect +from django.views import View + +from plane.authentication.provider.oauth.microsoft import MicrosoftOAuthProvider +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 get_safe_redirect_url + + +class MicrosoftOauthInitiateEndpoint(View): + def get(self, request): + 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(next_path) + 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", + ) + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, + params=exc.get_error_dict())) + try: + state = uuid.uuid4().hex + provider = MicrosoftOAuthProvider(request=request, state=state) + request.session["state"] = state + return HttpResponseRedirect(provider.get_auth_url()) + except AuthenticationException as e: + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, + params=e.get_error_dict())) + + +class MicrosoftCallbackEndpoint(View): + def get(self, request): + next_path = request.GET.get("next_path") + code = request.GET.get("code") + state = request.GET.get("state") + if not code or not state: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MICROSOFT_OAUTH_PROVIDER_ERROR"], + error_message="MICROSOFT_OAUTH_PROVIDER_ERROR", + ) + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, + params=exc.get_error_dict())) + try: + provider = MicrosoftOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) + user = provider.authenticate() + user_login(request=request, user=user, is_app=True) + path = next_path or get_redirection_path(user=user) + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=path, params={})) + except AuthenticationException as e: + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, + params=e.get_error_dict())) diff --git a/apps/api/plane/authentication/views/space/microsoft.py b/apps/api/plane/authentication/views/space/microsoft.py new file mode 100644 index 00000000000..8975f05a393 --- /dev/null +++ b/apps/api/plane/authentication/views/space/microsoft.py @@ -0,0 +1,70 @@ +# 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 django.http import HttpResponseRedirect +from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme + +from plane.authentication.provider.oauth.microsoft import MicrosoftOAuthProvider +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 AuthenticationException, AUTHENTICATION_ERROR_CODES +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + + +class MicrosoftOauthInitiateSpaceEndpoint(View): + def get(self, request): + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + 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", + ) + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, + params=exc.get_error_dict())) + try: + state = uuid.uuid4().hex + provider = MicrosoftOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(get_safe_redirect_url( + base_url=auth_url, next_path=None, params={})) + except AuthenticationException as e: + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, + params=e.get_error_dict())) + + +class MicrosoftCallbackSpaceEndpoint(View): + def get(self, request): + next_path = request.GET.get("next_path") + code = request.GET.get("code") + state = request.GET.get("state") + stored_state = request.session.get("state") + if state != stored_state or not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MICROSOFT_OAUTH_PROVIDER_ERROR"], + error_message="MICROSOFT_OAUTH_PROVIDER_ERROR", + ) + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, + params=exc.get_error_dict())) + try: + provider = MicrosoftOAuthProvider(request=request, code=code) + user = provider.authenticate() + user_login(request=request, user=user, is_space=True) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + return HttpResponseRedirect(base_host(request=request, is_space=True)) + except AuthenticationException as e: + return HttpResponseRedirect(get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, + params=e.get_error_dict())) diff --git a/apps/api/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py index a805411eee6..d5734c69ea9 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_MICROSOFT_ENABLED, EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN, ENABLE_EMAIL_PASSWORD, @@ -91,6 +92,10 @@ def get(self, request): "key": "IS_GITEA_ENABLED", "default": os.environ.get("IS_GITEA_ENABLED", "0"), }, + { + "key": "IS_MICROSOFT_ENABLED", + "default": os.environ.get("IS_MICROSOFT_ENABLED", "0"), + }, {"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")}, { "key": "ENABLE_MAGIC_LINK_LOGIN", @@ -123,6 +128,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_microsoft_enabled"] = IS_MICROSOFT_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/settings/common.py b/apps/api/plane/settings/common.py index 7f942a1bdca..b63ddf8a9c4 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -367,13 +367,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) # Cookie Settings -SESSION_COOKIE_SECURE = secure_origins +SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "false").lower() == "true" SESSION_COOKIE_HTTPONLY = True SESSION_ENGINE = "plane.db.models.session" SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800)) SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id") SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1" +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # Admin Cookie ADMIN_SESSION_COOKIE_NAME = "admin-session-id" diff --git a/apps/web/app/assets/logos/microsoft-logo.svg b/apps/web/app/assets/logos/microsoft-logo.svg new file mode 100644 index 00000000000..91fc188faac --- /dev/null +++ b/apps/web/app/assets/logos/microsoft-logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/web/core/hooks/oauth/core.tsx b/apps/web/core/hooks/oauth/core.tsx index 1614883fe86..359a0714619 100644 --- a/apps/web/core/hooks/oauth/core.tsx +++ b/apps/web/core/hooks/oauth/core.tsx @@ -15,6 +15,7 @@ import GithubLightLogo from "@/app/assets/logos/github-black.png?url"; import GithubDarkLogo from "@/app/assets/logos/github-dark.svg?url"; import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; import googleLogo from "@/app/assets/logos/google-logo.svg?url"; +import microsoftLogo from "@/app/assets/logos/microsoft-logo.svg?url"; // hooks import { useInstance } from "@/hooks/store/use-instance"; @@ -33,7 +34,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_microsoft_enabled)) || false; const oAuthOptions: TOAuthOption[] = [ { @@ -79,6 +81,15 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { }, enabled: config?.is_gitea_enabled, }, + { + id: "microsoft", + text: `${oauthActionText} with Microsoft`, + icon: Microsoft Logo, + onClick: () => { + window.location.assign(`${API_BASE_URL}/auth/microsoft/${next_path ? `?next_path=${next_path}` : ``}`); + }, + enabled: config?.is_microsoft_enabled, + }, ]; return {