From 73f0e41e790bd6da626c35d62716867004abcaab Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 6 May 2026 08:59:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20Tasker=20OIDC=20handoff=20=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D1=80=D0=BE=D0=B2=D0=B8=D0=B6?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plane-app/docker-compose.yaml | 4 + plane-app/plane.env | 4 + .../apps/api/plane/authentication/urls.py | 2 + .../plane/authentication/views/__init__.py | 2 +- .../plane/authentication/views/app/oidc.py | 216 +++++++++++++++++- 5 files changed, 223 insertions(+), 5 deletions(-) diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml index 3f66b65..7a7213d 100644 --- a/plane-app/docker-compose.yaml +++ b/plane-app/docker-compose.yaml @@ -61,7 +61,9 @@ x-app-env: &app-env PLANE_OIDC_SCOPE: ${PLANE_OIDC_SCOPE:-openid email profile groups} PLANE_OIDC_REQUIRED_GROUPS: ${PLANE_OIDC_REQUIRED_GROUPS:-nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user} PLANE_OIDC_AUTO_LINK_EMAIL: ${PLANE_OIDC_AUTO_LINK_EMAIL:-0} + PLANE_OIDC_AUTO_CREATE_USER: ${PLANE_OIDC_AUTO_CREATE_USER:-0} PLANE_OIDC_SYNC_PROFILE: ${PLANE_OIDC_SYNC_PROFILE:-1} + PLANE_NODEDC_SKIP_PROFILE_ONBOARDING: ${PLANE_NODEDC_SKIP_PROFILE_ONBOARDING:-0} PLANE_NODEDC_ACCESS_ENFORCEMENT: ${PLANE_NODEDC_ACCESS_ENFORCEMENT:-0} PLANE_NODEDC_ACCESS_CHECK_URL: ${PLANE_NODEDC_ACCESS_CHECK_URL:-} PLANE_NODEDC_ACCESS_TOKEN: ${PLANE_NODEDC_ACCESS_TOKEN:-} @@ -70,6 +72,8 @@ x-app-env: &app-env PLANE_NODEDC_ACCESS_CACHE_SECONDS: ${PLANE_NODEDC_ACCESS_CACHE_SECONDS:-0} PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL: ${PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL:-http://launcher.local.nodedc/} PLANE_NODEDC_GLOBAL_LOGOUT_URL: ${PLANE_NODEDC_GLOBAL_LOGOUT_URL:-http://launcher.local.nodedc/auth/logout?global=1&returnTo=/} + PLANE_NODEDC_HANDOFF_URL: ${PLANE_NODEDC_HANDOFF_URL:-http://launcher.local.nodedc/api/internal/handoff/consume} + PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS: ${PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS:-3} GUNICORN_WORKERS: 1 POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} POSTHOG_HOST: ${POSTHOG_HOST:-} diff --git a/plane-app/plane.env b/plane-app/plane.env index f63fcdc..2a3877c 100644 --- a/plane-app/plane.env +++ b/plane-app/plane.env @@ -101,6 +101,8 @@ PLANE_OIDC_REDIRECT_URI=http://task.local.nodedc/auth/oidc/callback PLANE_OIDC_SCOPE=openid email profile groups PLANE_OIDC_REQUIRED_GROUPS=nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user PLANE_OIDC_AUTO_LINK_EMAIL=1 +PLANE_OIDC_AUTO_CREATE_USER=1 +PLANE_NODEDC_SKIP_PROFILE_ONBOARDING=1 PLANE_NODEDC_ACCESS_ENFORCEMENT=1 PLANE_NODEDC_ACCESS_CHECK_URL=http://launcher.local.nodedc/api/internal/access/check PLANE_NODEDC_ACCESS_TOKEN= @@ -109,3 +111,5 @@ PLANE_NODEDC_ACCESS_TIMEOUT_SECONDS=3 PLANE_NODEDC_ACCESS_CACHE_SECONDS=0 PLANE_NODEDC_ACCESS_DENIED_REDIRECT_URL=http://launcher.local.nodedc/ PLANE_NODEDC_GLOBAL_LOGOUT_URL=http://launcher.local.nodedc/auth/logout?global=1&returnTo=/ +PLANE_NODEDC_HANDOFF_URL=http://launcher.local.nodedc/api/internal/handoff/consume +PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS=3 diff --git a/plane-src/apps/api/plane/authentication/urls.py b/plane-src/apps/api/plane/authentication/urls.py index 1e595fd..3803c08 100644 --- a/plane-src/apps/api/plane/authentication/urls.py +++ b/plane-src/apps/api/plane/authentication/urls.py @@ -21,6 +21,7 @@ from .views import ( MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint, + NodeDCHandoffEndpoint, NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint, SignInAuthEndpoint, @@ -56,6 +57,7 @@ urlpatterns = [ path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"), path("oidc/callback/", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback"), path("oidc/callback", NodeDCOIDCCallbackEndpoint.as_view(), name="nodedc-oidc-callback-no-slash"), + path("nodedc/handoff/", NodeDCHandoffEndpoint.as_view(), name="nodedc-handoff"), path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"), path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"), # signout diff --git a/plane-src/apps/api/plane/authentication/views/__init__.py b/plane-src/apps/api/plane/authentication/views/__init__.py index 72a8e87..298c3b8 100644 --- a/plane-src/apps/api/plane/authentication/views/__init__.py +++ b/plane-src/apps/api/plane/authentication/views/__init__.py @@ -12,7 +12,7 @@ from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint -from .app.oidc import NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint +from .app.oidc import NodeDCHandoffEndpoint, NodeDCOIDCCallbackEndpoint, NodeDCOIDCInitiateEndpoint from .app.signout import SignOutAuthEndpoint diff --git a/plane-src/apps/api/plane/authentication/views/app/oidc.py b/plane-src/apps/api/plane/authentication/views/app/oidc.py index 7db7677..0b5fb9e 100644 --- a/plane-src/apps/api/plane/authentication/views/app/oidc.py +++ b/plane-src/apps/api/plane/authentication/views/app/oidc.py @@ -1,5 +1,6 @@ import base64 import hashlib +import logging import os import secrets from urllib.parse import urlencode @@ -8,19 +9,22 @@ import jwt import requests from django.core.cache import cache from django.http import HttpResponseRedirect +from django.utils.crypto import get_random_string from django.utils import timezone from django.views import View from plane.authentication.utils.host import base_host from plane.authentication.utils.login import user_login from plane.authentication.utils.redirection_path import get_redirection_path -from plane.db.models import ExternalIdentityLink, User +from plane.authentication.views.nodedc_logout import get_nodedc_internal_token +from plane.db.models import ExternalIdentityLink, Profile, User from plane.utils.path_validator import get_safe_redirect_url, validate_next_path OIDC_SESSION_KEY = "nodedc_oidc" OIDC_PROVIDER = "authentik" DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user" +logger = logging.getLogger(__name__) class NodeDCOIDCInitiateEndpoint(View): @@ -98,7 +102,9 @@ class NodeDCOIDCCallbackEndpoint(View): claims=claims, groups=groups, auto_link=config["auto_link_email"], + auto_create=config["auto_create_user"], sync_profile=config["sync_profile"], + skip_profile_onboarding=config["skip_profile_onboarding"], ) if user is None or not user.is_active: @@ -111,6 +117,53 @@ class NodeDCOIDCCallbackEndpoint(View): return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={})) +class NodeDCHandoffEndpoint(View): + def get(self, request): + config = get_oidc_config() + base_url = base_host(request=request, is_app=True) + next_path = validate_next_path(request.GET.get("next_path", "")) + token = request.GET.get("token", "") + + try: + handoff = consume_launcher_handoff(token) + except (RuntimeError, requests.RequestException, ValueError): + logger.warning("NODEDC handoff failed", exc_info=True) + return oidc_login_redirect(base_url, next_path) + + handoff_user = handoff.get("user") or {} + groups = normalize_groups(handoff_user.get("groups")) + + if not has_required_group(groups): + return oidc_error_redirect(base_url, next_path, "handoff_access_denied") + + claims = { + "sub": str(handoff_user.get("subject") or handoff_user.get("authentikUserId") or handoff_user.get("id") or ""), + "email": str(handoff_user.get("email") or "").strip().lower(), + "name": handoff_user.get("name"), + "preferred_username": handoff_user.get("email"), + "picture": handoff_user.get("avatarUrl"), + "groups": groups, + "email_verified": True, + } + + user = resolve_linked_user( + claims=claims, + groups=groups, + auto_link=config["auto_link_email"], + auto_create=config["auto_create_user"], + sync_profile=config["sync_profile"], + skip_profile_onboarding=config["skip_profile_onboarding"], + ) + + if user is None or not user.is_active: + return oidc_error_redirect(base_url, next_path, "handoff_user_not_linked") + + 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_url, next_path=path, params={})) + + def get_oidc_config(): issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip() client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip() @@ -127,10 +180,44 @@ def get_oidc_config(): "redirect_uri": redirect_uri, "scope": os.environ.get("PLANE_OIDC_SCOPE", "openid email profile groups"), "auto_link_email": os.environ.get("PLANE_OIDC_AUTO_LINK_EMAIL", "0") == "1", + "auto_create_user": os.environ.get("PLANE_OIDC_AUTO_CREATE_USER", "0") == "1", "sync_profile": os.environ.get("PLANE_OIDC_SYNC_PROFILE", "1") == "1", + "skip_profile_onboarding": os.environ.get("PLANE_NODEDC_SKIP_PROFILE_ONBOARDING", "0") == "1", + "handoff_url": os.environ.get( + "PLANE_NODEDC_HANDOFF_URL", + "http://launcher.local.nodedc/api/internal/handoff/consume", + ).strip(), } +def consume_launcher_handoff(token): + if not token: + raise RuntimeError("NODEDC handoff token is missing") + + config = get_oidc_config() + internal_token = get_nodedc_internal_token() + + if not config["handoff_url"] or not internal_token: + raise RuntimeError("NODEDC handoff is not configured") + + response = requests.post( + config["handoff_url"], + json={ + "token": token, + "serviceSlug": os.environ.get("PLANE_NODEDC_ACCESS_SERVICE_SLUG", "task-manager"), + }, + headers={"Authorization": f"Bearer {internal_token}"}, + timeout=float(os.environ.get("PLANE_NODEDC_HANDOFF_TIMEOUT_SECONDS", "3")), + ) + response.raise_for_status() + payload = response.json() + + if not payload.get("ok"): + raise RuntimeError("NODEDC handoff was rejected") + + return payload + + def load_discovery(issuer): response = requests.get(f"{issuer}.well-known/openid-configuration", timeout=10) response.raise_for_status() @@ -192,19 +279,26 @@ def has_required_group(groups): return bool(required_groups.intersection(set(groups))) -def resolve_linked_user(claims, groups, auto_link, sync_profile): +def resolve_linked_user(claims, groups, auto_link, auto_create, sync_profile, skip_profile_onboarding): subject = str(claims.get("sub") or "") email = str(claims.get("email") or "").strip().lower() - if not subject: + if not subject or not email: return None link = ExternalIdentityLink.objects.select_related("user").filter( provider=OIDC_PROVIDER, subject=subject, - status=ExternalIdentityLink.Status.ACTIVE, ).first() + if link and link.status != ExternalIdentityLink.Status.ACTIVE: + logger.warning( + "NODEDC OIDC denied disabled external identity link: provider=%s subject_hash=%s", + OIDC_PROVIDER, + hash_subject(subject), + ) + return None + if link is None and auto_link and email: user = User.objects.filter(email__iexact=email, is_active=True).first() if user: @@ -214,7 +308,28 @@ def resolve_linked_user(claims, groups, auto_link, sync_profile): defaults={"user": user, "email": email, "groups": groups}, ) + if link is None and auto_create and email: + user, user_created = get_or_create_oidc_user(email=email, claims=claims) + link, _ = ExternalIdentityLink.objects.get_or_create( + provider=OIDC_PROVIDER, + subject=subject, + defaults={"user": user, "email": email, "groups": groups}, + ) + if user_created: + logger.info( + "NODEDC OIDC provisioned Tasker user: user_id=%s email_hash=%s subject_hash=%s", + user.id, + hash_email(email), + hash_subject(subject), + ) + if link is None: + logger.warning( + "NODEDC OIDC user is not linked: provider=%s email_hash=%s subject_hash=%s", + OIDC_PROVIDER, + hash_email(email), + hash_subject(subject), + ) return None link.email = email or link.email @@ -231,9 +346,92 @@ def resolve_linked_user(claims, groups, auto_link, sync_profile): update_fields.extend(sync_user_profile_from_claims(user, claims)) user.save(update_fields=list(dict.fromkeys(update_fields))) + + if skip_profile_onboarding: + ensure_nodedc_profile_onboarded(user) + return user +def get_or_create_oidc_user(email, claims): + user = User.objects.filter(email__iexact=email).first() + + if user: + return user, False + + username = build_unique_username(email) + display_name = first_string_claim(claims, "name", "preferred_username") or User.get_display_name(email) + given_name = first_string_claim(claims, "given_name") or "" + family_name = first_string_claim(claims, "family_name") or "" + avatar_url = first_string_claim(claims, "picture", "avatar_url", "avatar") or "" + + user = User.objects.create_user( + email=email, + username=username, + display_name=display_name, + first_name=given_name, + last_name=family_name, + avatar=avatar_url, + is_active=True, + is_managed=True, + is_password_autoset=True, + is_email_verified=bool(claims.get("email_verified", False)), + last_login_medium=OIDC_PROVIDER, + last_login_time=timezone.now(), + ) + return user, True + + +def ensure_nodedc_profile_onboarded(user): + profile, _ = Profile.objects.get_or_create(user=user) + onboarding_step = dict(profile.onboarding_step or {}) + required_onboarding_step = { + "profile_complete": True, + "workspace_create": True, + "workspace_invite": True, + "workspace_join": True, + } + next_onboarding_step = {**onboarding_step, **required_onboarding_step} + update_fields = [] + + if not profile.is_onboarded: + profile.is_onboarded = True + update_fields.append("is_onboarded") + + if not profile.is_tour_completed: + profile.is_tour_completed = True + update_fields.append("is_tour_completed") + + if profile.onboarding_step != next_onboarding_step: + profile.onboarding_step = next_onboarding_step + update_fields.append("onboarding_step") + + if update_fields: + update_fields.append("updated_at") + profile.save(update_fields=list(dict.fromkeys(update_fields))) + + +def build_unique_username(email): + base_username = email.split("@", 1)[0].strip().lower() or "nodedc-user" + username = normalize_username(base_username) + + if not User.objects.filter(username=username).exists(): + return username + + for _ in range(10): + candidate = f"{username}-{get_random_string(6).lower()}" + if not User.objects.filter(username=candidate).exists(): + return candidate + + return f"{username}-{get_random_string(12).lower()}" + + +def normalize_username(value): + normalized = "".join(char if char.isalnum() or char in {"_", "-", "."} else "-" for char in value) + normalized = normalized.strip("-._") + return normalized[:96] or "nodedc-user" + + def sync_user_profile_from_claims(user, claims): updated_fields = [] display_name = first_string_claim(claims, "name", "preferred_username") @@ -273,6 +471,16 @@ def first_string_claim(claims, *keys): return None +def hash_email(email): + normalized_email = str(email or "").strip().lower() + return hashlib.sha256(normalized_email.encode()).hexdigest()[:12] if normalized_email else "" + + +def hash_subject(subject): + normalized_subject = str(subject or "").strip() + return hashlib.sha256(normalized_subject.encode()).hexdigest()[:12] if normalized_subject else "" + + def normalize_logout_guard_value(value): return value.strip().lower() if isinstance(value, str) else ""