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:
,
+ onClick: () => {
+ window.location.assign(`${API_BASE_URL}/auth/microsoft/${next_path ? `?next_path=${next_path}` : ``}`);
+ },
+ enabled: config?.is_microsoft_enabled,
+ },
];
return {