import base64 import hashlib import logging import os import secrets from urllib.parse import urlencode 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.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): def get(self, request): config = get_oidc_config() next_path = validate_next_path(request.GET.get("next_path", "")) discovery = load_discovery(config["issuer"]) state = secrets.token_urlsafe(32) nonce = secrets.token_urlsafe(32) code_verifier = secrets.token_urlsafe(64) code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip("=") request.session[OIDC_SESSION_KEY] = { "state": state, "nonce": nonce, "code_verifier": code_verifier, "next_path": next_path, } request.session.save() params = { "response_type": "code", "client_id": config["client_id"], "redirect_uri": config["redirect_uri"], "scope": config["scope"], "state": state, "nonce": nonce, "code_challenge": code_challenge, "code_challenge_method": "S256", } if request.GET.get("prompt") == "login": params["prompt"] = "login" params["max_age"] = "0" return HttpResponseRedirect(f"{discovery['authorization_endpoint']}?{urlencode(params)}") class NodeDCOIDCCallbackEndpoint(View): def get(self, request): config = get_oidc_config() oidc_session = request.session.get(OIDC_SESSION_KEY) or {} next_path = oidc_session.get("next_path", "") base_url = base_host(request=request, is_app=True) if request.GET.get("error"): request.session.pop(OIDC_SESSION_KEY, None) return oidc_login_redirect(base_url, next_path) state = request.GET.get("state") code = request.GET.get("code") if not state or state != oidc_session.get("state") or not code: request.session.pop(OIDC_SESSION_KEY, None) return oidc_login_redirect(base_url, next_path) try: discovery = load_discovery(config["issuer"]) token_set = exchange_code(discovery, config, code, oidc_session.get("code_verifier")) claims = verify_id_token(discovery, config, token_set["id_token"], oidc_session.get("nonce")) except (KeyError, RuntimeError, requests.RequestException, jwt.PyJWTError): request.session.pop(OIDC_SESSION_KEY, None) return oidc_login_redirect(base_url, next_path) if is_logout_guard_active(claims): request.session.pop(OIDC_SESSION_KEY, None) return oidc_login_redirect(base_url, next_path, prompt_login=True) groups = normalize_groups(claims.get("groups")) if not has_required_group(groups): return oidc_error_redirect(base_url, next_path, "oidc_access_denied") 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, "oidc_user_not_linked") request.session.pop(OIDC_SESSION_KEY, None) 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={})) 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() client_secret = os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip() redirect_uri = os.environ.get("PLANE_OIDC_REDIRECT_URI", "").strip() if not issuer or not client_id or not client_secret or not redirect_uri: raise RuntimeError("Plane OIDC is not configured") return { "issuer": issuer.rstrip("/") + "/", "client_id": client_id, "client_secret": client_secret, "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() return response.json() def exchange_code(discovery, config, code, code_verifier): response = requests.post( discovery["token_endpoint"], data={ "grant_type": "authorization_code", "code": code, "redirect_uri": config["redirect_uri"], "code_verifier": code_verifier, }, auth=(config["client_id"], config["client_secret"]), timeout=10, ) response.raise_for_status() token_set = response.json() if not token_set.get("id_token"): raise RuntimeError("OIDC token response does not contain id_token") return token_set def verify_id_token(discovery, config, id_token, nonce): jwks_client = jwt.PyJWKClient(discovery["jwks_uri"]) signing_key = jwks_client.get_signing_key_from_jwt(id_token) claims = jwt.decode( id_token, signing_key.key, algorithms=["RS256"], audience=config["client_id"], issuer=discovery.get("issuer", config["issuer"]), ) if claims.get("nonce") != nonce: raise RuntimeError("OIDC nonce validation failed") return claims def normalize_groups(groups): if isinstance(groups, list): return list(dict.fromkeys(group for group in groups if isinstance(group, str))) if isinstance(groups, str) and groups: return [groups] return [] def has_required_group(groups): required_groups = { group.strip() for group in os.environ.get("PLANE_OIDC_REQUIRED_GROUPS", DEFAULT_REQUIRED_GROUPS).split(",") if group.strip() } return bool(required_groups.intersection(set(groups))) 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 or not email: return None link = ExternalIdentityLink.objects.select_related("user").filter( provider=OIDC_PROVIDER, subject=subject, ).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: link, _ = ExternalIdentityLink.objects.get_or_create( provider=OIDC_PROVIDER, subject=subject, 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 link.groups = groups link.last_login_at = timezone.now() link.save(update_fields=["email", "groups", "last_login_at", "updated_at"]) user = link.user user.last_login_medium = OIDC_PROVIDER user.last_login_time = timezone.now() update_fields = ["last_login_medium", "last_login_time", "updated_at"] if 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") given_name = first_string_claim(claims, "given_name") family_name = first_string_claim(claims, "family_name") avatar_url = first_string_claim(claims, "picture", "avatar_url", "avatar") if display_name and user.display_name != display_name: user.display_name = display_name updated_fields.append("display_name") if not given_name and display_name: name_parts = display_name.split(" ", 1) given_name = name_parts[0] family_name = family_name or (name_parts[1] if len(name_parts) > 1 else "") if given_name and user.first_name != given_name: user.first_name = given_name updated_fields.append("first_name") if family_name is not None and user.last_name != family_name: user.last_name = family_name updated_fields.append("last_name") if avatar_url and user.avatar != avatar_url: user.avatar = avatar_url updated_fields.append("avatar") return updated_fields def first_string_claim(claims, *keys): for key in keys: value = claims.get(key) if isinstance(value, str) and value: return value 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 "" def get_logout_guard_cache_key(kind, value): normalized_value = normalize_logout_guard_value(value) return f"nodedc:logout-guard:{kind}:{normalized_value}" if normalized_value else "" def get_logout_guard_cache_keys(claims): keys = set() subject_key = get_logout_guard_cache_key("subject", claims.get("sub")) email_key = get_logout_guard_cache_key("email", claims.get("email")) if subject_key: keys.add(subject_key) if email_key: keys.add(email_key) return sorted(keys) def get_claim_auth_time(claims): auth_time = claims.get("auth_time") try: return int(auth_time) except (TypeError, ValueError): return 0 def get_logout_guard_time(claims): guard_times = [] for cache_key in get_logout_guard_cache_keys(claims): value = cache.get(cache_key) try: guard_times.append(int(value)) except (TypeError, ValueError): pass return max(guard_times) if guard_times else 0 def clear_logout_guard(claims): cache_keys = get_logout_guard_cache_keys(claims) if cache_keys: cache.delete_many(cache_keys) def is_logout_guard_active(claims): logout_guard_time = get_logout_guard_time(claims) if not logout_guard_time: return False auth_time = get_claim_auth_time(claims) if auth_time > logout_guard_time: clear_logout_guard(claims) return False return True def oidc_error_redirect(base_url, next_path, error_code): return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=next_path, params={"error": error_code})) def oidc_login_redirect(base_url, next_path, prompt_login=False): params = {} if validate_next_path(next_path): params["next_path"] = next_path if prompt_login: params["prompt"] = "login" query_string = f"?{urlencode(params)}" if params else "" return HttpResponseRedirect(f"{base_url.rstrip('/')}/auth/oidc/login/{query_string}")