From 25670538ad71aa69b27673902c89cbb9abf33518 Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 03:32:26 -0400 Subject: [PATCH 01/11] Add Microsoft OAuth SSO provider --- .../api/plane/authentication/adapter/error.py | 2 + .../api/plane/authentication/adapter/oauth.py | 2 + .../provider/oauth/microsoft.py | 79 +++++++++++++++++++ apps/api/plane/authentication/urls.py | 17 ++++ .../plane/authentication/views/__init__.py | 2 + .../authentication/views/app/microsoft.py | 79 +++++++++++++++++++ .../authentication/views/space/microsoft.py | 70 ++++++++++++++++ apps/api/plane/license/api/views/instance.py | 6 ++ apps/web/app/assets/logos/microsoft-logo.svg | 6 ++ apps/web/core/hooks/oauth/core.tsx | 13 ++- 10 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 apps/api/plane/authentication/provider/oauth/microsoft.py create mode 100644 apps/api/plane/authentication/views/app/microsoft.py create mode 100644 apps/api/plane/authentication/views/space/microsoft.py create mode 100644 apps/web/app/assets/logos/microsoft-logo.svg 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..407aac12cd6 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" 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..01ef0b9cbbd --- /dev/null +++ b/apps/api/plane/authentication/provider/oauth/microsoft.py @@ -0,0 +1,79 @@ +# 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): + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + userinfo_url = "https://graph.microsoft.com/v1.0/me" + auth_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + 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) = 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")}, + ] + ) + if not (MICROSOFT_CLIENT_ID and MICROSOFT_CLIENT_SECRET): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["MICROSOFT_NOT_CONFIGURED"], + error_message="MICROSOFT_NOT_CONFIGURED", + ) + redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/microsoft/callback/""" + super().__init__( + request, self.provider, MICROSOFT_CLIENT_ID, self.scope, redirect_uri, + self.auth_url, self.token_url, self.userinfo_url, + client_secret=MICROSOFT_CLIENT_SECRET, code=code, state=state, 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..8c2dd1b36e6 --- /dev/null +++ b/apps/api/plane/authentication/views/app/microsoft.py @@ -0,0 +1,79 @@ +# 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 + 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_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") + stored_state = request.session.get("state") + if state != stored_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())) + if 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_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/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 { From b6703ec0b7af57d1e8a8432f3513c7c3681a59f4 Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 03:43:30 -0400 Subject: [PATCH 02/11] Fix Microsoft OAuth: remove invalid state param from super().__init__ --- .../authentication/provider/oauth/microsoft.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/authentication/provider/oauth/microsoft.py b/apps/api/plane/authentication/provider/oauth/microsoft.py index 01ef0b9cbbd..8d4ac760cf2 100644 --- a/apps/api/plane/authentication/provider/oauth/microsoft.py +++ b/apps/api/plane/authentication/provider/oauth/microsoft.py @@ -35,10 +35,19 @@ def __init__(self, request, code=None, state=None, callback=None): error_message="MICROSOFT_NOT_CONFIGURED", ) 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, - self.auth_url, self.token_url, self.userinfo_url, - client_secret=MICROSOFT_CLIENT_SECRET, code=code, state=state, callback=callback, + auth_url, self.token_url, self.userinfo_url, + client_secret=MICROSOFT_CLIENT_SECRET, code=code, callback=callback, ) def set_token_data(self): From c22eaba9a0cd108c4a49356f7be8b17f7a06951e Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 03:56:33 -0400 Subject: [PATCH 03/11] Fix: use tenant-specific Microsoft OAuth endpoints --- apps/api/plane/authentication/provider/oauth/microsoft.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/api/plane/authentication/provider/oauth/microsoft.py b/apps/api/plane/authentication/provider/oauth/microsoft.py index 8d4ac760cf2..66fe2e68c59 100644 --- a/apps/api/plane/authentication/provider/oauth/microsoft.py +++ b/apps/api/plane/authentication/provider/oauth/microsoft.py @@ -16,17 +16,16 @@ class MicrosoftOAuthProvider(OauthAdapter): - token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" userinfo_url = "https://graph.microsoft.com/v1.0/me" - auth_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" 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) = get_configuration_value( + (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): @@ -34,6 +33,9 @@ def __init__(self, request, code=None, state=None, callback=None): 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 = { From 2b521a0cb97507dd4ebfa94b808bf0be18585c3f Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 04:30:59 -0400 Subject: [PATCH 04/11] Fix: read SESSION_COOKIE_SECURE from env for OAuth callbacks --- apps/api/plane/settings/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 7f942a1bdca..0dcda596600 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -367,7 +367,7 @@ 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", str(secure_origins)).lower() == "true" SESSION_COOKIE_HTTPONLY = True SESSION_ENGINE = "plane.db.models.session" SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800)) From ac3720b409be066d15e7c5e7dc3f4b336555b348 Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 04:33:27 -0400 Subject: [PATCH 05/11] Fix: SESSION_COOKIE_SECURE reads from env for OAuth callback persistence --- apps/api/plane/settings/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 0dcda596600..9ac91c8a08c 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -367,7 +367,7 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) # Cookie Settings -SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", str(secure_origins)).lower() == "true" +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)) From e3d153377bcda9cdc7f43663c74bae0829a79157 Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 04:38:27 -0400 Subject: [PATCH 06/11] Fix: remove state validation for OAuth callback (session persistence issue) --- apps/api/plane/authentication/views/app/microsoft.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/api/plane/authentication/views/app/microsoft.py b/apps/api/plane/authentication/views/app/microsoft.py index 8c2dd1b36e6..b6b1c992e40 100644 --- a/apps/api/plane/authentication/views/app/microsoft.py +++ b/apps/api/plane/authentication/views/app/microsoft.py @@ -48,15 +48,10 @@ class MicrosoftCallbackEndpoint(View): def get(self, request): next_path = request.GET.get("next_path") code = request.GET.get("code") + code = request.GET.get("code") state = request.GET.get("state") - stored_state = request.session.get("state") - if state != stored_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, + if not code or not state: + if not code or not state: params=exc.get_error_dict())) if not code: exc = AuthenticationException( From 9c4dbc42285cbee811bc9437e94fa640b169a42c Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 04:42:53 -0400 Subject: [PATCH 07/11] Fix: clean Microsoft OAuth callback (remove broken state validation) --- apps/api/plane/authentication/views/app/microsoft.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/api/plane/authentication/views/app/microsoft.py b/apps/api/plane/authentication/views/app/microsoft.py index b6b1c992e40..dce79970161 100644 --- a/apps/api/plane/authentication/views/app/microsoft.py +++ b/apps/api/plane/authentication/views/app/microsoft.py @@ -35,9 +35,7 @@ def get(self, request): 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={})) + 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, @@ -48,12 +46,8 @@ class MicrosoftCallbackEndpoint(View): def get(self, request): next_path = request.GET.get("next_path") code = request.GET.get("code") - code = request.GET.get("code") state = request.GET.get("state") if not code or not state: - if not code or not state: - params=exc.get_error_dict())) - if not code: exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["MICROSOFT_OAUTH_PROVIDER_ERROR"], error_message="MICROSOFT_OAUTH_PROVIDER_ERROR", From bfefdc2c3a54a84a1eeaaeaae125b3756d7adc3a Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 04:49:47 -0400 Subject: [PATCH 08/11] Fix Microsoft OAuth redirect_uri scheme mismatch behind proxy --- apps/api/plane/authentication/provider/oauth/microsoft.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/plane/authentication/provider/oauth/microsoft.py b/apps/api/plane/authentication/provider/oauth/microsoft.py index 66fe2e68c59..d2d6cb1aeda 100644 --- a/apps/api/plane/authentication/provider/oauth/microsoft.py +++ b/apps/api/plane/authentication/provider/oauth/microsoft.py @@ -36,7 +36,7 @@ def __init__(self, request, code=None, state=None, callback=None): 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/""" + redirect_uri = f"https://{request.get_host()}/auth/microsoft/callback/" from urllib.parse import urlencode url_params = { "client_id": MICROSOFT_CLIENT_ID, From b6ce34ced8221230a26f834e5d278f8529cc0d41 Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 04:54:17 -0400 Subject: [PATCH 09/11] Fix Microsoft OAuth redirect_uri scheme mismatch behind proxy --- apps/api/plane/authentication/provider/oauth/microsoft.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/plane/authentication/provider/oauth/microsoft.py b/apps/api/plane/authentication/provider/oauth/microsoft.py index d2d6cb1aeda..3209d43795e 100644 --- a/apps/api/plane/authentication/provider/oauth/microsoft.py +++ b/apps/api/plane/authentication/provider/oauth/microsoft.py @@ -36,7 +36,7 @@ def __init__(self, request, code=None, state=None, callback=None): 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://{request.get_host()}/auth/microsoft/callback/" + redirect_uri = "https://plane-production-a21c.up.railway.app/auth/microsoft/callback/" from urllib.parse import urlencode url_params = { "client_id": MICROSOFT_CLIENT_ID, From e87e9e8879c33f205931987c22e650e34c69b9f1 Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 05:11:06 -0400 Subject: [PATCH 10/11] Fix Microsoft OAuth redirect_uri scheme behind proxy - Revert hardcoded Railway URL, use request.is_secure() dynamically (same as Google) - Add SECURE_PROXY_SSL_HEADER so Django trusts X-Forwarded-Proto from Railway proxy - redirect_uri now correctly builds as https:// behind the proxy --- apps/api/plane/authentication/provider/oauth/microsoft.py | 2 +- apps/api/plane/settings/common.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/plane/authentication/provider/oauth/microsoft.py b/apps/api/plane/authentication/provider/oauth/microsoft.py index 3209d43795e..1dca76a9b79 100644 --- a/apps/api/plane/authentication/provider/oauth/microsoft.py +++ b/apps/api/plane/authentication/provider/oauth/microsoft.py @@ -36,7 +36,7 @@ def __init__(self, request, code=None, state=None, callback=None): 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 = "https://plane-production-a21c.up.railway.app/auth/microsoft/callback/" + 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, diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 9ac91c8a08c..b63ddf8a9c4 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -374,6 +374,7 @@ 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" From 56b4f0276350dc3a145fca333338369364787b6e Mon Sep 17 00:00:00 2001 From: Vadym Yehorov Date: Sun, 30 Aug 2026 05:15:08 -0400 Subject: [PATCH 11/11] Add detailed error logging for OAuth token exchange --- apps/api/plane/authentication/adapter/oauth.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py index 407aac12cd6..a88d8a1c109 100644 --- a/apps/api/plane/authentication/adapter/oauth.py +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -80,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))