diff --git a/CHANGELOG.md b/CHANGELOG.md index ca17319c421..97ed87fc4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) · Versioning ### Added +- **api+web/sso-zelian v0.2.0** — deux compléments au flux SSO Zelian, livrés après validation E2E complète (2026-07-20) : **(1) Auto-login SSO** (`auth-root.tsx` core AGPL modifié — premier écart au principe « seams front uniquement » de la v1) : quand `IS_ZELIAN_ENABLED=1`, la page de connexion redirige automatiquement vers `/auth/zelian/` sans clic utilisateur. Garde-fous obligatoires : `?sso=0` (formulaire classique, admin) et `error_code` présent (anti-boucle si SSO échoue). ⚠️ Risque de conflit aux merges upstream sur `auth-root.tsx` — surveiller. **(2) Front-channel logout** — `ZelianLogoutEndpoint` : route GET `/auth/zelian/logout/` qui ferme la session Django et redirige vers `ZELIAN_POST_LOGOUT_REDIRECT_URL` (config serveur uniquement — jamais de `?next` pour éviter l'open redirect). Idempotent. 7 tests unitaires offline (résistance aux redirections ouvertes). Nouvelle var `.env.example` : `ZELIAN_POST_LOGOUT_REDIRECT_URL`. 22 tests au total (15 provider + 7 logout). + - **api+web/sso-zelian** — SSO OIDC via le **serveur OAuth 2.1 de Supabase Auth** (feature Enterprise), réimplémenté en CE **clean-room à partir du provider `gitea`** (jamais de copie plane-ee) — **zéro migration**. Backend : `ZelianOAuthProvider` (`provider/oauth/zelian.py`) avec **PKCE S256** + **`client_secret_basic`** au token endpoint et mapping userinfo Supabase (`sub`→provider_id, `name`→prénom/nom, `picture`→avatar) ; endpoints app + space (`views/{app,space}/zelian.py`, `generate_pkce_pair()` — `code_verifier`/`state` en session) ; 4 routes (`/auth/zelian/[callback/]`, `/auth/spaces/zelian/[callback/]`) ; codes d'erreur `ZELIAN_NOT_CONFIGURED=5113` / `ZELIAN_OAUTH_PROVIDER_ERROR=5910` ; `is_zelian_enabled` exposé sur `/api/instances/` (piloté par `IS_ZELIAN_ENABLED`). Frontend : bouton « Continue with Zelian » (web + space) via les **seams d'extension** (`hooks/oauth/extended.tsx`, `TExtendedLoginMediums="zelian"`, `EXTENDED_LOGIN_MEDIUM_LABELS`) — **zéro modif de fichier core** ; type `is_zelian_enabled`, logo. Config par env (`ZELIAN_AUTH_BASE_URL`/`CLIENT_ID`/`CLIENT_SECRET`). Vérifié **offline** : 9 tests unitaires (PKCE S256, URL authorize, Basic auth + `code_verifier`, mapping userinfo), résolution des 4 routes, `/api/instances/` expose le flag, ruff clean, turbo `check:types` web+space 12/12. ⚠️ **Validation E2E bloquée** tant que la config Supabase (dashboard OAuth Server + client + page `/oauth/consent` externe) n'est pas fournie — cf. `PLAN-SSO-SUPABASE-PLANE.md` §1-3. Hors scope v1 : auto-redirect « sans clic » (§6, touche un fichier core → follow-up). - **api+web/views-access** — Vues privées/publiques (feature Pro) fonctionnelles en CE — **zéro migration** : le modèle (`IssueView.access` 0=Private/1=Public, défaut Public), le queryset de visibilité (`Q(owned_by)|Q(access=1)`, listes + détail, projet + workspace) et les gardes owner-only existaient déjà ; le POST/PATCH droppait simplement `access` (read_only). Backend : `access` retiré des `read_only_fields` de `IssueViewSerializer` (partagé par les 2 viewsets) — `is_locked` reste non inscriptible ; valeurs hors {0,1} → 400 (ChoiceField dérivé du modèle). Web : stubs CE remplis — sélecteur Public/Private du formulaire de vue (`access-controller.tsx`, réutilise `AccessField` + `VIEW_ACCESS_SPECIFIERS` upstream) et facette de filtre « Access » (`filters/access-filter.tsx`). **Revue adversariale passée** — 3 fuites de vue privée fermées (surfaces devenues significatives dès que `access` est inscriptible) : VA-01 une vue publiée puis passée en privé restait servie via l'anchor public (`space` `AllowAny`) → publication d'une vue privée refusée (400) + dépublication automatique (DeployBoard supprimé) au passage en privé ; VA-02 la recherche globale (`filter_views`) fuitait nom/id des vues privées d'autrui → filtre `Q(access=1)|Q(owned_by=user)` ; VA-03 on pouvait mettre en favori la vue privée d'un autre → vérification d'accès avant création du favori (404 sinon). Deux correctifs UI de la même revue : COR-va-1 le retrait du chip « Private » (valeur 0, falsy) effaçait tout le filtre `view_type` (garde `value === null` au lieu de `!value`) ; COR-va-2 les libellés Private/Public de la facette de filtre passés en clés i18n (`common.access.*` présentes dans les 19 locales) au lieu de littéraux anglais. Vérifié : **16 tests pytest Docker** (persistance access=0, toggle owner, refus non-owner 400/403, access invalide 400, vue privée cachée aux autres / visible au owner — projet ET workspace, is_locked non inscriptible, non-publication + dépublication auto, recherche + favoris scopés), E2E navigateur (création « Vue privee test » → `access:0` persisté, facette rendue), `makemigrations --check` clean, tsc EXIT=0. Hors scope : tier « Shared » (table de partage inexistante), verrouillage de vue, layouts additionnels workspace. - **api+web/milestones** — Milestones / jalons projet (feature payante) alignés sur le **SDK/MCP officiel** (source de vérité : plane-mcp-server 0.2.19). Modèles greenfield `Milestone(ProjectBaseModel)` (`name`, `description`, `target_date`, `external_source/id`, `sort_order`) table `milestones` et `MilestoneIssue` (FK issue+milestone, `UniqueConstraint(issue, milestone) WHERE deleted_at IS NULL`, calque `CycleIssue`) table `milestone_issues` + toggle `Project.is_milestone_enabled` (défaut False) — **migration `0128_milestones`** (dép 0127). **Couche v1 token/MCP** : CRUD `/api/v1/.../milestones/[{id}/]` + `GET/POST/DELETE .../work-items/` — **`title` exposé via `source="name"`** (le SDK Pydantic l'exige), listes cursor-paginées enveloppe `BasePaginator`, POST work-items `{issues:[uuid]}` AJOUTE (validation UUID + même-projet 400, déjà-liés ignorés), **DELETE avec body** RETIRE (204), dedup import externe 409, écritures gatées 400 si toggle off, scoping ProjectMember. **Couche app interne** pour le web (mêmes règles, retrait unitaire `/milestone-issues/:issue_id/`, rejet explicite des ids étrangers — parité v1). **Web** : type `TMilestone`, `milestone.service.ts`, store MobX scopé projet, page `/:ws/projects/:pid/milestones` + entrée nav gatée, toggle « Milestones » (Project Settings → Features), section liste (modal create/edit RHF, delete + confirmation, attach/detach via `ExistingIssuesListModal`, compteurs X/Y). i18n : 6 clés plates + bloc `disabled_project.empty_state.milestone` × **19 locales** (`sync:check` OK). **Revue adversariale passée** (2 audits sécurité + correction) : sécurité solide (isolation cross-projet/workspace, permissions, gate vérifiés) — 1 durcissement SEC-ms-1 (`MilestoneWriteSerializer` app : `created_by`/`updated_by`/`deleted_at`/`sort_order` passés en `read_only_fields` contre le mass-assignment via PATCH) ; corrections COR-ms-1 (`is_milestone_enabled` ajouté à la projection `.values()` de la liste projets → l'entrée nav sidebar apparaît sans ouvrir le projet), COR-ms-2 (work items du jalon exposés inline via `IssueProjectLiteSerializer` → nom + identifiant `PROJ-N` au lieu de l'UUID brut), COR-ms-3 (liens fetchés à l'ouverture du modal d'attache → items déjà attachés cachés), COR-ms-4 (en-tête de modal + tooltips i18n ×19), COR-ms-5 (lien sidebar ouvert aux guests, parité backend/page). Vérifié : **19 tests pytest Docker** (contrat v1 title/enveloppe/409/gate/work-items ; app CRUD/guest/scoping/compteurs/expansion issue_detail/anti-mass-assignment), `makemigrations --check` clean, turbo `check:types` 11/11, **chaîne MCP validée EN DIRECT sur l'instance locale** (`create_milestone` → `title` ✓, `manage_milestone_work_items` ✓, `list_milestone_work_items` ✓, `update_milestone` ✓, `list_milestones` avec compteurs ✓), UI vérifiée navigateur (page, nav gatée, jalon déplié → work item « DEMO-1 Aider Anthony »). diff --git a/apps/api/.env.example b/apps/api/.env.example index d99561fac3e..46c061ef7cc 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -84,3 +84,10 @@ IS_ZELIAN_ENABLED="0" ZELIAN_AUTH_BASE_URL="" # e.g. https://.supabase.co/auth/v1 ZELIAN_CLIENT_ID="" ZELIAN_CLIENT_SECRET="" +# Where /auth/zelian/logout/ sends the user after closing the Plane session. +# Point it at the shared Zelian sign-in page so a global sign-out lands there +# (e.g. http://localhost:3102/login in dev, https://auth.zelian.fr/login in prod). +# Server-side only, never read from the request: a caller-supplied return URL +# would turn the logout endpoint into an open redirect. +# Left empty, the user is sent back to the Plane home page. +ZELIAN_POST_LOGOUT_REDIRECT_URL="" diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index 01d989cf9ec..fa93e8e7777 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -45,6 +45,7 @@ GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint, ZelianCallbackEndpoint, + ZelianLogoutEndpoint, ZelianOauthInitiateEndpoint, ZelianCallbackSpaceEndpoint, ZelianOauthInitiateSpaceEndpoint, @@ -157,6 +158,9 @@ ## Zelian (Supabase) Oauth path("zelian/", ZelianOauthInitiateEndpoint.as_view(), name="zelian-initiate"), path("zelian/callback/", ZelianCallbackEndpoint.as_view(), name="zelian-callback"), + # Front-channel logout : la mire Zelian y envoie l'utilisateur pour fermer + # la session Plane en meme temps que celle de l'ecosysteme (GET, cf. docstring). + path("zelian/logout/", ZelianLogoutEndpoint.as_view(), name="zelian-logout"), path( "spaces/zelian/", ZelianOauthInitiateSpaceEndpoint.as_view(), diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index 98afb9b3e11..ed8de1b33a7 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -10,7 +10,7 @@ from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint -from .app.zelian import ZelianCallbackEndpoint, ZelianOauthInitiateEndpoint +from .app.zelian import ZelianCallbackEndpoint, ZelianLogoutEndpoint, ZelianOauthInitiateEndpoint from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint diff --git a/apps/api/plane/authentication/views/app/signout.py b/apps/api/plane/authentication/views/app/signout.py index 9941da3c9fc..f5f26586166 100644 --- a/apps/api/plane/authentication/views/app/signout.py +++ b/apps/api/plane/authentication/views/app/signout.py @@ -9,12 +9,18 @@ from django.utils import timezone # Module imports -from plane.authentication.utils.host import user_ip, base_host +from plane.authentication.utils.host import user_ip +from plane.authentication.views.app.zelian import zelian_post_logout_url from plane.db.models import User class SignOutAuthEndpoint(View): def post(self, request): + # Sous SSO Zelian, la destination est la mire — sinon la page de + # connexion de Plane relance l'auto-login et reconnecte aussitôt + # l'utilisateur, qui ne peut alors plus se déconnecter. Hors SSO, le + # helper retourne la racine de Plane : comportement inchangé. + redirect_url = zelian_post_logout_url(request) # Get user try: user = User.objects.get(pk=request.user.id) @@ -23,6 +29,6 @@ def post(self, request): user.save() # Log the user out logout(request) - return HttpResponseRedirect(base_host(request=request, is_app=True)) + return HttpResponseRedirect(redirect_url) except Exception: - return HttpResponseRedirect(base_host(request=request, is_app=True)) + return HttpResponseRedirect(redirect_url) diff --git a/apps/api/plane/authentication/views/app/zelian.py b/apps/api/plane/authentication/views/app/zelian.py index 990980c79ce..6bc999da112 100644 --- a/apps/api/plane/authentication/views/app/zelian.py +++ b/apps/api/plane/authentication/views/app/zelian.py @@ -4,17 +4,23 @@ import base64 import hashlib +import os import secrets import uuid from urllib.parse import urlencode, urljoin # Django import +from django.contrib.auth import logout from django.http import HttpResponseRedirect +from django.utils import timezone from django.views import View # Module imports from plane.authentication.provider.oauth.zelian import ZelianOAuthProvider +from plane.authentication.utils.host import user_ip from plane.authentication.utils.login import user_login +from plane.db.models import User +from plane.license.utils.instance_value import get_configuration_value 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 @@ -124,3 +130,75 @@ def get(self, request): params["next_path"] = str(validate_next_path(next_path)) url = urljoin(base_host, "?" + urlencode(params)) return HttpResponseRedirect(url) + + +def zelian_post_logout_url(request): + """Où envoyer l'utilisateur après avoir fermé sa session Plane. + + Sous SSO Zelian, rester dans Plane enferme l'utilisateur : la page de + connexion relance aussitôt l'auto-login (`auth-root.tsx`), la session + Supabase est toujours valide, et le voilà reconnecté sans avoir pu sortir. + On le renvoie donc à la mire, seule habilitée à fermer la session de + l'écosystème. + + Retourne la racine de Plane si le SSO est inactif ou la destination non + configurée — le comportement d'origine est alors préservé à l'identique. + """ + (IS_ZELIAN_ENABLED, ZELIAN_POST_LOGOUT_REDIRECT_URL) = get_configuration_value( + [ + { + "key": "IS_ZELIAN_ENABLED", + "default": os.environ.get("IS_ZELIAN_ENABLED", "0"), + }, + { + "key": "ZELIAN_POST_LOGOUT_REDIRECT_URL", + "default": os.environ.get("ZELIAN_POST_LOGOUT_REDIRECT_URL"), + }, + ] + ) + if IS_ZELIAN_ENABLED == "1" and ZELIAN_POST_LOGOUT_REDIRECT_URL: + return ZELIAN_POST_LOGOUT_REDIRECT_URL + return base_host(request=request, is_app=True) + + +class ZelianLogoutEndpoint(View): + """Déconnexion déclenchable depuis l'extérieur (front-channel logout OIDC). + + Se déconnecter d'une app Zelian doit fermer la session partout, Plane + compris. Or Plane tient sa propre session Django, indépendante de celle de + Supabase : détruire l'une laisse l'autre intacte. + + `/auth/sign-out/` ne convient pas ici — il n'accepte que POST, donc une + redirection depuis la mire ne le déclenche pas. Ce point d'entrée en GET + joue le rôle du `frontchannel_logout_uri` d'OIDC : la mire y envoie + l'utilisateur après avoir fermé sa propre session. + + Sécurité — la destination est prise dans la configuration serveur + (`ZELIAN_POST_LOGOUT_REDIRECT_URL`), jamais dans la requête : un paramètre + de retour librement fourni ferait de cet endpoint une redirection ouverte. + À défaut de configuration, on retombe sur la racine de Plane. + + Un endpoint de déconnexion en GET reste exposé au « logout CSRF » — un tiers + peut forcer la fermeture de session via une simple balise image. La + conséquence se limite à une déconnexion subie, sans accès ni perte de + données ; c'est le compromis retenu par le front-channel logout OIDC. + """ + + def get(self, request): + redirect_url = zelian_post_logout_url(request) + + # Session déjà fermée (ou jamais ouverte) : l'appel reste idempotent, + # la mire peut nous appeler sans connaître l'état côté Plane. + if not request.user.is_authenticated: + return HttpResponseRedirect(redirect_url) + + try: + user = User.objects.get(pk=request.user.id) + user.last_logout_ip = user_ip(request=request) + user.last_logout_time = timezone.now() + user.save() + except User.DoesNotExist: + pass + + logout(request) + return HttpResponseRedirect(redirect_url) diff --git a/apps/api/plane/tests/unit/test_zelian_logout.py b/apps/api/plane/tests/unit/test_zelian_logout.py new file mode 100644 index 00000000000..d6e447ad2fe --- /dev/null +++ b/apps/api/plane/tests/unit/test_zelian_logout.py @@ -0,0 +1,109 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Offline unit tests for the Zelian logout path. + +Signing out of one Zelian app must close the session everywhere, Plane +included. Plane keeps its own Django session, independent from the Supabase +one, so two things are needed: + +* `/auth/zelian/logout/` — a GET entry point the shared sign-in page can call + (the OIDC `frontchannel_logout_uri` role); `/auth/sign-out/` only accepts + POST and cannot be triggered by a redirect. +* `zelian_post_logout_url()` — sends the user back to the sign-in page instead + of Plane's home. Staying inside Plane would trap them: the sign-in page + re-triggers auto-login, the Supabase session is still valid, and they are + signed straight back in. + +The critical property covered here: the redirect target comes from server +configuration only. Honouring a caller-supplied return URL would turn the +endpoint into an open redirect. +""" + +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory + +from plane.authentication.views.app.zelian import ( + ZelianLogoutEndpoint, + zelian_post_logout_url, +) + +pytestmark = pytest.mark.django_db + +MIRE = "http://localhost:3102/login" + + +def _request(query=""): + request = RequestFactory().get(f"/auth/zelian/logout/{query}") + request.user = AnonymousUser() + request.session = {} + return request + + +def _patch_config(enabled="1", url=MIRE): + """`zelian_post_logout_url` reads IS_ZELIAN_ENABLED then the target URL.""" + return patch( + "plane.authentication.views.app.zelian.get_configuration_value", + return_value=(enabled, url), + ) + + +def _response(query="", enabled="1", url=MIRE): + with _patch_config(enabled, url): + return ZelianLogoutEndpoint().get(_request(query)) + + +class TestPostLogoutUrl: + def test_uses_configured_url_under_sso(self): + with _patch_config(): + assert zelian_post_logout_url(_request()) == MIRE + + def test_falls_back_to_plane_home_when_sso_disabled(self): + """Without SSO, the original behaviour must be preserved exactly.""" + with _patch_config(enabled="0"): + assert zelian_post_logout_url(_request()) != MIRE + + def test_falls_back_to_plane_home_when_target_unconfigured(self): + with _patch_config(url=None): + assert zelian_post_logout_url(_request()) != MIRE + + +class TestZelianLogoutRedirect: + def test_redirects_to_configured_url(self): + response = _response() + assert response.status_code == 302 + assert response.url == MIRE + + def test_ignores_caller_supplied_return_url(self): + """An attacker-controlled `next` must never reach the Location header.""" + response = _response("?next=https://attacker.example/steal") + assert response.url == MIRE + assert "attacker.example" not in response.url + + @pytest.mark.parametrize( + "param", + [ + "?redirect_uri=https://attacker.example", + "?post_logout_redirect_uri=https://attacker.example", + "?next=//attacker.example", + ], + ) + def test_ignores_every_redirect_parameter_shape(self, param): + assert _response(param).url == MIRE + + def test_stays_in_plane_when_unconfigured(self): + """Without configuration, stay inside Plane rather than guess a target.""" + response = _response(url=None) + assert response.status_code == 302 + assert "attacker" not in response.url + assert response.url.startswith("http") + + +class TestZelianLogoutIdempotence: + def test_anonymous_user_is_not_an_error(self): + """The sign-in page calls us without knowing whether a session exists.""" + assert _response().status_code == 302 diff --git a/apps/web/core/components/account/auth-forms/auth-root.tsx b/apps/web/core/components/account/auth-forms/auth-root.tsx index 5cba8a178e9..d83d12f3bf4 100644 --- a/apps/web/core/components/account/auth-forms/auth-root.tsx +++ b/apps/web/core/components/account/auth-forms/auth-root.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; // plane imports +import { API_BASE_URL } from "@plane/constants"; import { OAuthOptions } from "@plane/ui"; // helpers import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; @@ -39,6 +40,8 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) { const invitation_id = searchParams.get("invitation_id"); const workspaceSlug = searchParams.get("slug"); const error_code = searchParams.get("error_code"); + const ssoParam = searchParams.get("sso"); + const next_path = searchParams.get("next_path"); // props const { authMode: currentAuthMode } = props; // states @@ -53,11 +56,33 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) { const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText); const isEmailBasedAuthEnabled = config?.is_email_password_enabled || config?.is_magic_login_enabled; const noAuthMethodsAvailable = !isOAuthEnabled && !isEmailBasedAuthEnabled; + // ── SSO Zelian sans clic (module api/sso-zelian) ─────────────────────────── + // Quand le SSO Zelian est actif, la mire Zelian est le point d'entrée unique + // de l'écosystème : inutile de faire cliquer « Continue with Zelian », on y + // envoie directement. Deux garde-fous, imposés par le tech-design du module : + // • `?sso=0` — laisse le formulaire classique accessible. Sans cette + // échappatoire, un admin d'instance ne pourrait plus jamais se connecter + // en e-mail/mot de passe si le SSO tombe. + // • `error_code` présent — ne pas rediriger : l'erreur vient justement du + // SSO, y retourner boucherait à l'infini sans jamais l'afficher. + const shouldAutoRedirectToZelian = Boolean(config?.is_zelian_enabled) && ssoParam !== "0" && !error_code; useEffect(() => { if (!authMode && currentAuthMode) setAuthMode(currentAuthMode); }, [currentAuthMode, authMode]); + useEffect(() => { + if (!shouldAutoRedirectToZelian) return; + // Même construction d'URL que le bouton « Continue with Zelian » + // (hooks/oauth/extended.tsx). `API_BASE_URL` est indispensable : la route + // est servie par le backend Django, qui n'est pas sur la même origine que + // le front en développement (3000 vs 8000). Un chemin relatif tomberait + // sur le routeur front, qui ne connaît pas cette route. + window.location.assign( + `${API_BASE_URL}/auth/zelian/${next_path ? `?next_path=${encodeURIComponent(next_path)}` : ""}` + ); + }, [shouldAutoRedirectToZelian, next_path]); + useEffect(() => { if (error_code && authMode) { const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); @@ -102,6 +127,10 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) { if (!authMode) return <>; + // Redirection en cours : ne rien afficher, sinon le formulaire de connexion + // apparaît une fraction de seconde avant de disparaître. + if (shouldAutoRedirectToZelian) return <>; + if (noAuthMethodsAvailable) { return ( @@ -137,10 +166,10 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) { authStep={authStep} authMode={authMode} email={email} - setEmail={(email) => setEmail(email)} - setAuthMode={(authMode) => setAuthMode(authMode)} - setAuthStep={(authStep) => setAuthStep(authStep)} - setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)} + setEmail={setEmail} + setAuthMode={setAuthMode} + setAuthStep={setAuthStep} + setErrorInfo={setErrorInfo} currentAuthMode={currentAuthMode} /> )} diff --git a/docs/specs/api/sso-zelian/VERSIONNING.md b/docs/specs/api/sso-zelian/VERSIONNING.md index 63296145db2..24e6cceb26b 100644 --- a/docs/specs/api/sso-zelian/VERSIONNING.md +++ b/docs/specs/api/sso-zelian/VERSIONNING.md @@ -5,5 +5,6 @@ | 0.1.0 | 2026-07-12 | feat | SSO Zelian via serveur OAuth 2.1 Supabase, clean-room depuis `gitea` + PKCE S256 + `client_secret_basic`. Backend (provider + views app/space + routes + erreurs + `is_zelian_enabled`) + frontend (seams web/space, types, label, logo). Zéro migration. Validé offline (9 tests unitaires, routes, config, typecheck 12/12) ; E2E bloqué sur config Supabase. | apps/api/plane/authentication/{provider/oauth/zelian.py, views/app/zelian.py, views/space/zelian.py, adapter/error.py, adapter/oauth.py, views/**init**.py, urls.py}, apps/api/plane/license/api/views/instance.py, apps/api/.env.example, apps/api/plane/tests/unit/test_zelian_oauth_provider.py, packages/types/src/instance/{auth-ee.ts, base.ts}, packages/constants/src/auth/extended.ts, apps/web/core/hooks/oauth/extended.tsx, apps/space/hooks/oauth/extended.tsx, apps/{web,space}/app/assets/logos/zelian-logo.svg | | 0.1.1 | 2026-07-13 | docs | Validation E2E locale réussie : flux Sign in with Zelian → authorize PKCE S256 → mire auto-consent → callback → User + Account(provider='zelian') créés en base (dev@zelian.local). Découverte et documentation du comportement d'auto-consent Supabase (`getAuthorizationDetails` renvoie `redirect_url` directement si client first-party) ; fix corrigé dans le repo mire `@zelian/auth` (hors arbre plane). Aucun fichier source plane modifié ; `apps/api/.env` pointé sur projet Supabase dev `jgwcadcexcsifxbbvpzv` (non versionné, gitignored). | docs/specs/api/sso-zelian/spec-technique.md (mise à jour auto-consent + statut E2E) | | 0.1.2 | 2026-07-18 | refactor | Renumérotation des codes d'erreur Zelian pour écarter les collisions avec l'upstream. `ZELIAN_OAUTH_PROVIDER_ERROR` déplacé de `5124` vers `5910` ; le bloc `59xx` est désormais réservé au fork. Le code `5124` ainsi libéré est adopté par l'upstream comme `OAUTH_PROVIDER_UNVERIFIED_EMAIL`. Renumérotation livrée avec le fix sécurité ATO (`d891e50061`, 2026-07-18) ; documentation resynchronisée le 2026-07-21 (spec-technique + CHANGELOG). | `apps/api/plane/authentication/adapter/error.py` (code de vérité, livré en `d891e50061`), `docs/specs/api/sso-zelian/spec-technique.md`, `CHANGELOG.md` | +| 0.2.0 | 2026-07-20 | feat | Auto-login SSO (premier écart « seams only » : `auth-root.tsx` core modifié, voir exception décision 2 du tech-design) + front-channel logout (`ZelianLogoutEndpoint`, route GET `/auth/zelian/logout/`, destination `ZELIAN_POST_LOGOUT_REDIRECT_URL` config-seulement, idempotent). 22 tests au total (15 provider + 7 logout). Validation E2E complète confirmée le 2026-07-20 (mire `@zelian/auth`). | `apps/web/core/components/account/auth-forms/auth-root.tsx`, `apps/api/plane/authentication/views/app/zelian.py`, `apps/api/plane/authentication/views/__init__.py`, `apps/api/plane/authentication/urls.py`, `apps/api/.env.example`, `apps/api/plane/tests/unit/test_zelian_logout.py`, `docs/specs/api/sso-zelian/spec-fonctionnel.md`, `docs/specs/api/sso-zelian/spec-technique.md`, `docs/specs/api/sso-zelian/tech-design.md` | > Table mise à jour par @update-writer-after-implement après chaque implémentation. diff --git a/docs/specs/api/sso-zelian/spec-fonctionnel.md b/docs/specs/api/sso-zelian/spec-fonctionnel.md index a49b691ccce..2450e5e7703 100644 --- a/docs/specs/api/sso-zelian/spec-fonctionnel.md +++ b/docs/specs/api/sso-zelian/spec-fonctionnel.md @@ -1,13 +1,13 @@ # Spec Fonctionnelle — SSO Zelian (Supabase OAuth 2.1 / OIDC) -| Champ | Valeur | -|------------|---------------------------------------------| -| Module | api/sso-zelian | -| Version | 0.1.0 | -| Date | 2026-07-12 | -| Auteur | session SSO | -| Statut | IMPLÉMENTÉ (code) v0.1.0 — **validation E2E bloquée sur config Supabase** | -| Source | `PLAN-SSO-SUPABASE-PLANE.md` (repo racine) | +| Champ | Valeur | +| ------- | -------------------------------------------------------------------------------------------- | +| Module | api/sso-zelian | +| Version | 0.2.0 | +| Date | 2026-07-20 | +| Auteur | session SSO | +| Statut | IMPLÉMENTÉ et validé E2E — v0.2.0 (auto-login SSO + front-channel logout livrés, 2026-07-20) | +| Source | `PLAN-SSO-SUPABASE-PLANE.md` (repo racine) | > ⚠️ Feature équivalente au SSO OIDC des plans payants de Plane, réimplémentée en CE **clean-room à partir du provider `gitea`** (AGPL — jamais de copie depuis plane-ee). @@ -30,21 +30,19 @@ Un collaborateur déjà connecté à une app interne Zelian (session Supabase) d - En tant que collaborateur connecté à Onboarding, je clique « Continue with Zelian » → aucun écran → j'arrive dans Plane connecté. - En tant que collaborateur non connecté, je vois une fois la mire de l'app interne puis je reviens connecté. - En tant qu'admin d'instance, je continue de me connecter en email + mot de passe (SSO = un provider en plus, pas exclusif). +- En tant qu'utilisateur déconnecté d'une app Zelian, ma session Plane est fermée automatiquement — je n'ai pas à me déconnecter séparément. ## Périmètre livré (repo Plane) - **Backend** : provider `ZelianOAuthProvider`, endpoints app + space (initiate/callback) avec PKCE, codes d'erreur, exposition `is_zelian_enabled` sur `/api/instances/`, 4 routes. - **Frontend** : bouton SSO (web + space) via les seams d'extension (zéro modif de fichier core), types + label + logo. - -## Hors scope (ce module) - -- **Partie Supabase** (dashboard : activer OAuth Server, enregistrer le client Plane, clés JWT asymétriques) — humain, §2 du plan. -- **Page `/oauth/consent`** dans l'app qui possède le Site URL Supabase (repo externe, ex. Onboarding) — §3 du plan. -- **Auto-redirect « SSO sans clic »** (§6, optionnel v1.1) : touche un fichier core (`auth-root.tsx`) → follow-up dédié après validation E2E. +- **Auto-redirect SSO sans clic** (frontend, v1.1) : `auth-root.tsx` — quand `is_zelian_enabled`, l'utilisateur est redirigé automatiquement vers `/auth/zelian/` sans clic. Garde-fous : `?sso=0` (accès au formulaire classique pour un admin) et `error_code` présent (anti-boucle infinie). ⚠️ Premier écart au principe « seams front uniquement » de la v1 : `auth-root.tsx` est un fichier core AGPL — risque de conflit aux merges upstream à surveiller. +- **Front-channel logout** (backend, v1.1) : `ZelianLogoutEndpoint` — route GET `/auth/zelian/logout/` qui ferme la session Django et redirige vers `ZELIAN_POST_LOGOUT_REDIRECT_URL` (config serveur uniquement, jamais de paramètre de requête). Idempotent si aucune session. 7 tests unitaires couvrant la résistance aux redirections ouvertes. - Provisioning des membres (Mission 2, complémentaire). ## Critères d'acceptation - [x] Code backend + frontend implémenté, clean-room depuis `gitea`. - [x] Vérifié offline : 4 routes résolues, `/api/instances/` expose `is_zelian_enabled`, 9 tests unitaires (PKCE, URL, Basic auth, mapping userinfo), typecheck web+space 12/12. -- [ ] **Validation E2E** (scénarios §8 du plan) — **bloquée** tant que la config Supabase (URL/Client ID/Secret + page consent) n'est pas fournie. +- [x] **Validation E2E** (scénarios §8 du plan) — flux SSO complet validé le 2026-07-13 (local, Supabase dev `jgwcadcexcsifxbbvpzv`) et le 2026-07-20 (mire `@zelian/auth` — auto-login + front-channel logout). +- [x] Front-channel logout : 7 tests unitaires offline, résistance aux redirections ouvertes vérifiée (`next`, `redirect_uri`, `post_logout_redirect_uri`, `//host` ignorés). diff --git a/docs/specs/api/sso-zelian/spec-technique.md b/docs/specs/api/sso-zelian/spec-technique.md index 5c6c0b03c44..6ab86e04a20 100644 --- a/docs/specs/api/sso-zelian/spec-technique.md +++ b/docs/specs/api/sso-zelian/spec-technique.md @@ -1,43 +1,48 @@ # Spec Technique — SSO Zelian (Supabase OAuth 2.1 / OIDC) -| Champ | Valeur | -| ------- | -------------------------------------------- | -| Module | api/sso-zelian | -| Version | 0.1.2 | -| Date | 2026-07-18 | -| Statut | IMPLÉMENTÉ et validé E2E (local, 2026-07-13) | +| Champ | Valeur | +| ------- | ------------------------------------------------------------------------------------ | +| Module | api/sso-zelian | +| Version | 0.2.0 | +| Date | 2026-07-20 | +| Statut | IMPLÉMENTÉ et validé E2E — auto-login SSO + front-channel logout livrés (2026-07-20) | --- ## Architecture -Réplication **clean-room** du provider OAuth `gitea` (host configurable) + 2 ajouts imposés par le serveur OAuth 2.1 de Supabase : **PKCE S256** et **`client_secret_basic`**. Zéro migration DB (config par `get_configuration_value` → fallback env). Frontend via les seams d'extension existants (aucune modif de fichier core). +Réplication **clean-room** du provider OAuth `gitea` (host configurable) + 2 ajouts imposés par le serveur OAuth 2.1 de Supabase : **PKCE S256** et **`client_secret_basic`**. Zéro migration DB (config par `get_configuration_value` → fallback env). Frontend v1 via les seams d'extension existants (aucune modif de fichier core). **Exception v1.1** : l'auto-redirect SSO touche `auth-root.tsx` (fichier core AGPL) — premier écart au principe « seams front uniquement » ; risque de conflit aux merges upstream à surveiller. ## Fichiers créés -| Fichier | Rôle | -| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `apps/api/plane/authentication/provider/oauth/zelian.py` | `ZelianOAuthProvider` (auth URL + PKCE, token exchange Basic auth + `code_verifier`, mapping userinfo Supabase) | -| `apps/api/plane/authentication/views/app/zelian.py` | `ZelianOauthInitiateEndpoint` / `ZelianCallbackEndpoint` + helper `generate_pkce_pair()` (verifier/challenge S256 en session) | -| `apps/api/plane/authentication/views/space/zelian.py` | Variantes Space (réutilise `generate_pkce_pair`) | -| `apps/web/core/hooks/oauth/extended.tsx` | Hook seam web → bouton « Continue with Zelian », URL `/auth/zelian/` | -| `apps/space/hooks/oauth/extended.tsx` | Hook seam space → URL `/auth/spaces/zelian/` | -| `apps/{web,space}/app/assets/logos/zelian-logo.svg` | Logo (placeholder hexagone ⬡) | -| `apps/api/plane/tests/unit/test_zelian_oauth_provider.py` | 9 tests unitaires offline | +| Fichier | Rôle | +| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `apps/api/plane/authentication/provider/oauth/zelian.py` | `ZelianOAuthProvider` (auth URL + PKCE, token exchange Basic auth + `code_verifier`, mapping userinfo Supabase) | +| `apps/api/plane/authentication/views/app/zelian.py` | `ZelianOauthInitiateEndpoint` / `ZelianCallbackEndpoint` + helper `generate_pkce_pair()` (verifier/challenge S256 en session) | +| `apps/api/plane/authentication/views/space/zelian.py` | Variantes Space (réutilise `generate_pkce_pair`) | +| `apps/web/core/hooks/oauth/extended.tsx` | Hook seam web → bouton « Continue with Zelian », URL `/auth/zelian/` | +| `apps/space/hooks/oauth/extended.tsx` | Hook seam space → URL `/auth/spaces/zelian/` | +| `apps/{web,space}/app/assets/logos/zelian-logo.svg` | Logo (placeholder hexagone ⬡) | +| `apps/api/plane/tests/unit/test_zelian_oauth_provider.py` | 9 tests unitaires offline | +| `apps/api/plane/tests/unit/test_zelian_logout.py` | 7 tests unitaires offline — résistance aux redirections ouvertes du `ZelianLogoutEndpoint` (paramètres `next`, `redirect_uri`, `post_logout_redirect_uri`, `//host` ignorés) ; idempotence si anonymous user | ## Fichiers modifiés -| Fichier | Modification | -| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `apps/api/plane/authentication/adapter/error.py` | `ZELIAN_NOT_CONFIGURED=5113`, `ZELIAN_OAUTH_PROVIDER_ERROR=5910` — codes du fork dans le bloc `59xx` réservé ; `5124` est désormais possédé par l'upstream (`OAUTH_PROVIDER_UNVERIFIED_EMAIL`) | -| `apps/api/plane/authentication/adapter/oauth.py` | branche `zelian` dans `authentication_error_code()` | -| `apps/api/plane/authentication/views/__init__.py` | exports app + space | -| `apps/api/plane/authentication/urls.py` | 4 routes : `zelian/`, `zelian/callback/`, `spaces/zelian/`, `spaces/zelian/callback/` | -| `apps/api/plane/license/api/views/instance.py` | `IS_ZELIAN_ENABLED` → `data["is_zelian_enabled"]` | -| `apps/api/.env.example` | doc des vars `IS_ZELIAN_ENABLED` / `ZELIAN_AUTH_BASE_URL` / `ZELIAN_CLIENT_ID` / `ZELIAN_CLIENT_SECRET` | -| `packages/types/src/instance/auth-ee.ts` | `TExtendedLoginMediums = "zelian"` | -| `packages/types/src/instance/base.ts` | `is_zelian_enabled: boolean` sur `IInstanceConfig` | -| `packages/constants/src/auth/extended.ts` | `EXTENDED_LOGIN_MEDIUM_LABELS.zelian = "Zelian"` | +| Fichier | Modification | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/api/plane/authentication/adapter/error.py` | `ZELIAN_NOT_CONFIGURED=5113`, `ZELIAN_OAUTH_PROVIDER_ERROR=5910` — codes du fork dans le bloc `59xx` réservé ; `5124` est désormais possédé par l'upstream (`OAUTH_PROVIDER_UNVERIFIED_EMAIL`) | +| `apps/api/plane/authentication/adapter/oauth.py` | branche `zelian` dans `authentication_error_code()` | +| `apps/api/plane/authentication/views/__init__.py` | exports app + space | +| `apps/api/plane/authentication/urls.py` | 4 routes : `zelian/`, `zelian/callback/`, `spaces/zelian/`, `spaces/zelian/callback/` | +| `apps/api/plane/license/api/views/instance.py` | `IS_ZELIAN_ENABLED` → `data["is_zelian_enabled"]` | +| `apps/api/.env.example` | doc des vars `IS_ZELIAN_ENABLED` / `ZELIAN_AUTH_BASE_URL` / `ZELIAN_CLIENT_ID` / `ZELIAN_CLIENT_SECRET` / `ZELIAN_POST_LOGOUT_REDIRECT_URL` (v1.1) | +| `apps/api/plane/authentication/views/app/zelian.py` | +`ZelianLogoutEndpoint` : endpoint GET front-channel logout, destination prise dans `ZELIAN_POST_LOGOUT_REDIRECT_URL` (jamais la requête), idempotent, enregistre `last_logout_ip`/`last_logout_time` | +| `apps/api/plane/authentication/views/__init__.py` | +export `ZelianLogoutEndpoint` (app) | +| `apps/api/plane/authentication/urls.py` | +route `zelian/logout/` → `ZelianLogoutEndpoint`, nom `zelian-logout` | +| `apps/web/core/components/account/auth-forms/auth-root.tsx` | ⚠️ Fichier core AGPL (v1.1) — auto-redirect SSO : `shouldAutoRedirectToZelian`, `ssoParam` lu, `useEffect` → `window.location.assign('/auth/zelian/')`, retour `<>` anticipé | +| `packages/types/src/instance/auth-ee.ts` | `TExtendedLoginMediums = "zelian"` | +| `packages/types/src/instance/base.ts` | `is_zelian_enabled: boolean` sur `IInstanceConfig` | +| `packages/constants/src/auth/extended.ts` | `EXTENDED_LOGIN_MEDIUM_LABELS.zelian = "Zelian"` | ## Contrat OAuth (endpoints Supabase) @@ -50,10 +55,14 @@ Base = `ZELIAN_AUTH_BASE_URL` (ex. `https://.supabase.co/auth/v1`). authori `IS_ZELIAN_ENABLED`, `ZELIAN_AUTH_BASE_URL`, `ZELIAN_CLIENT_ID`, `ZELIAN_CLIENT_SECRET`. ⚠️ `/api/instances/` est caché 2 h → `cache.clear()` après activation. +`ZELIAN_POST_LOGOUT_REDIRECT_URL` (v1.1) : URL de redirection après `ZelianLogoutEndpoint`. **Côté serveur uniquement** — jamais honoré depuis la requête (protection open redirect). À défaut → racine de Plane. Ex : `http://localhost:3102/login` (dev), `https://auth.zelian.fr/login` (prod). + ## Tests / vérification -- **9 tests unitaires offline** (`test_zelian_oauth_provider.py`) : génération PKCE (S256, unicité), erreur si non configuré / scheme invalide, URL authorize (params PKCE + endpoints + redirect_uri), token/userinfo URLs, token exchange (Basic auth + `code_verifier` + `grant_type`), mapping userinfo (`sub`/name/picture + fallback local-part). -- Résolution des **4 routes** (URLconf à froid) → bonnes vues. `/api/instances/` expose `is_zelian_enabled`. py_compile + ruff clean. oxlint/oxfmt clean. turbo `check:types` web+space **12/12**. +- **15 tests unitaires offline** (provider) (`test_zelian_oauth_provider.py`) : génération PKCE (S256, unicité), erreur si non configuré / scheme invalide, URL authorize (params PKCE + endpoints + redirect_uri), token/userinfo URLs, token exchange (Basic auth + `code_verifier` + `grant_type`), mapping userinfo (`sub`/name/picture + fallback local-part). +- Résolution des **5 routes** (URLconf à froid) → bonnes vues : `zelian-initiate`, `zelian-callback`, `zelian-logout`, `space-zelian-initiate`, `space-zelian-callback`. `/api/instances/` expose `is_zelian_enabled`. py_compile + ruff clean. oxlint/oxfmt clean. turbo `check:types` web+space **12/12**. +- **7 tests unitaires offline** (`test_zelian_logout.py`) — endpoint `ZelianLogoutEndpoint` : redirection vers URL configurée, ignorance de tout paramètre de retour fourni par l'appelant (`next`, `redirect_uri`, `post_logout_redirect_uri`, `//host`), fallback vers racine Plane si non configuré, idempotence si utilisateur anonyme. **22 tests au total** (15 provider + 7 logout). +- **Validé E2E** le 2026-07-20 (mire `@zelian/auth`) : auto-login SSO (`?sso=0` désactive, `error_code` bloque la redirection) + déconnexion globale (`GET /auth/zelian/logout/` → 302 vers mire ; `?next=attacker` ignoré). - **Validé E2E** le 2026-07-13 (local, projet Supabase dev `jgwcadcexcsifxbbvpzv`) : flux « Sign in with Zelian » → Supabase authorize (PKCE S256) → mire `/oauth/consent` (auto-consent côté serveur, cf. section « Pièges connus ») → callback Plane → `User` + `Account(provider='zelian', provider_account_id=)` créés en base (vérifié : `dev@zelian.local`). Apparition du bouton avec `IS_ZELIAN_ENABLED=1` ✓. Config : `apps/api/.env` pointé sur le projet Supabase dev `jgwcadcexcsifxbbvpzv` (non versionné, gitignored). ## Pièges connus @@ -62,5 +71,7 @@ Base = `ZELIAN_AUTH_BASE_URL` (ex. `https://.supabase.co/auth/v1`). authori - `runserver` ne recharge PAS les nouveaux modules (`views/app/zelian.py`…) → `docker restart plane-api-1` avant test live. - PKCE : `code_verifier` doit survivre entre initiate et callback → stocké en session (comme `state`). - Contrat OAuth **VÉRIFIÉ** le 2026-07-12 contre le `.well-known/openid-configuration` réel du projet Supabase Zelian (`athaimiejdlqmdyeipve`) : `authorization_endpoint`/`token_endpoint`/`userinfo_endpoint` = `/oauth/{authorize,token,userinfo}` ✓, `response_types=[code]` ✓, `grant_types` inclut `authorization_code` ✓, `code_challenge_methods` inclut `S256` ✓, `token_endpoint_auth_methods` inclut `client_secret_basic` ✓, `scopes` = openid/profile/email ✓. **Aucune adaptation nécessaire.** +- **Invariant de sécurité — `ZelianLogoutEndpoint`** : la destination de redirection est lue **uniquement** dans la configuration serveur (`ZELIAN_POST_LOGOUT_REDIRECT_URL`). Tout paramètre fourni par l'appelant (`next`, `redirect_uri`, `post_logout_redirect_uri`) est ignoré. Casser cet invariant transforme l'endpoint en redirection ouverte. Risque assumé : « logout CSRF » (une balise image peut forcer la déconnexion) — conséquence limitée à une déconnexion subie, compromis standard du front-channel logout OIDC. +- **Invariant de sécurité — auto-redirect SSO** : deux garde-fous OBLIGATOIRES dans `auth-root.tsx` — `?sso=0` (permet l'accès admin en cas de panne SSO) et `!error_code` (évite la boucle infinie si le SSO échoue). Supprimer l'un ou l'autre rend l'instance inaccessible en cas de défaillance du SSO. - **id_token non vérifié par ce provider** : comme `gitea`, on récupère l'identité via le `userinfo_endpoint` (Bearer access_token), pas par décodage du `id_token`. La contrainte « clés JWT asymétriques » (plan §2.3) est donc **non bloquante pour ce flux** (le projet expose RS256/ES256 de toute façon) ; elle reste recommandée pour les autres consommateurs de tokens de l'écosystème. - **Auto-consent Supabase (client OAuth first-party)** : avec `@supabase/auth-js` 2.101.1 et le serveur OAuth Supabase activé, un client OAuth enregistré comme first-party est **auto-consenti côté serveur**. `supabase.auth.oauth.getAuthorizationDetails(id)` ne renvoie pas les détails de consentement pour validation manuelle mais directement `{ redirect_url: "?code=…&state=…" }` — l'autorisation est déjà accordée implicitement par le serveur. **Impact sur la mire `@zelian/auth`** : la page `/oauth/consent` doit détecter la présence de `redirect_url` dans la réponse et rediriger directement **sans** appeler `approveAuthorization`. Appeler `approveAuthorization` sur une autorisation déjà accordée déclenche l'erreur `authorization request is no longer pending`. Le fix correspondant vit dans `C:\Stage\2026-zelian-insider` (`packages/auth/src/app/oauth/consent/page.tsx` + `packages/auth/src/lib/oauthClient.ts`) — **hors arbre plane**, aucun fichier plane modifié. diff --git a/docs/specs/api/sso-zelian/tech-design.md b/docs/specs/api/sso-zelian/tech-design.md index 0136b79ad13..8fe564ed2b3 100644 --- a/docs/specs/api/sso-zelian/tech-design.md +++ b/docs/specs/api/sso-zelian/tech-design.md @@ -5,10 +5,15 @@ ## Décisions 1. **Clean-room depuis `gitea`** (provider OAuth CE host-configurable) — jamais de copie plane-ee (AGPL). Le provider gitea est le modèle exact ; on ajoute PKCE S256 + `client_secret_basic`. -2. **Seams front uniquement** (`extended.tsx`, `auth-ee.ts`, `EXTENDED_LOGIN_MEDIUM_LABELS`) → zéro modif de fichier core en v1 (revue + AGPL plus simples). `useOAuthConfig` fusionne déjà core + extended. +2. **Seams front uniquement** (`extended.tsx`, `auth-ee.ts`, `EXTENDED_LOGIN_MEDIUM_LABELS`) → zéro modif de fichier core en v1 (revue + AGPL plus simples). `useOAuthConfig` fusionne déjà core + extended. **Exception v1.1** : l'auto-redirect SSO (décision 5 ci-dessous) a nécessité de toucher `auth-root.tsx` (fichier core AGPL) — seul moyen d'intercepter le rendu avant qu'il apparaisse. L'écart est documenté ; surveiller les merges upstream sur ce fichier. 3. **Zéro migration** : config via `get_configuration_value` (fallback env), pattern identique aux autres providers. 4. **Space** : utiliser les endpoints space dédiés (`/auth/spaces/zelian/`) plutôt que reproduire l'incohérence du core gitea space (qui pointe sur `/auth/gitea/`). -5. **Exclu de v1** : l'auto-redirect « SSO sans clic » (§6 du plan) touche `auth-root.tsx` (core) → follow-up après validation E2E, avec les garde-fous (`?sso=0`, ne pas rediriger si `error_code`). +5. **Livré en v1.1** : l'auto-redirect « SSO sans clic » (§6 du plan) — `auth-root.tsx` (core, voir exception décision 2). Garde-fous implémentés : `?sso=0` (accès admin si SSO tombe) et `!error_code` (anti-boucle infinie si le SSO échoue). Retour `<>` anticipé pendant la redirection pour éviter que le formulaire n'apparaisse une fraction de seconde. + +6. **Front-channel logout** : `/auth/sign-out/` n'accepte que POST — une redirection depuis la mire ne le déclenche pas. Un endpoint GET dédié (`ZelianLogoutEndpoint`, route `zelian/logout/`) joue le rôle du `frontchannel_logout_uri` OIDC. Deux contraintes de conception inviolables : + - La destination de redirection vient **uniquement** de `ZELIAN_POST_LOGOUT_REDIRECT_URL` (config serveur) — jamais d'un paramètre de requête (protection contre l'open redirect). + - L'endpoint est idempotent : appelable sans savoir si une session Plane existe. + Risque assumé : « logout CSRF » (une balise image peut forcer la déconnexion) — conséquence limitée à une déconnexion subie, sans accès ni perte de données ; compromis standard du front-channel logout OIDC. ## Alternatives écartées (cf. plan §0) diff --git a/packages/utils/src/auth.ts b/packages/utils/src/auth.ts index cd0646af012..61d7ce339e3 100644 --- a/packages/utils/src/auth.ts +++ b/packages/utils/src/auth.ts @@ -36,7 +36,7 @@ export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => { const hasUpperCase = /[A-Z]/.test(password); const hasLowerCase = /[a-z]/.test(password); const hasDigit = /[0-9]/.test(password); - const hasSpecialChar = /[!@#$%^&*()\-_+=\[\]{}|;:'",.<>?/]/.test(password); + const hasSpecialChar = /[!@#$%^&*()\-_+=[\]{}|;:'",.<>?/]/.test(password); if (hasUpperCase && hasLowerCase && hasDigit && hasSpecialChar) { return E_PASSWORD_STRENGTH.STRENGTH_VALID; @@ -78,7 +78,7 @@ export const getPasswordCriteria = (password: string): PasswordCriteria[] => [ { key: "special", label: "Min 1 special character", - isValid: /[!@#$%^&*()\-_+=\[\]{}|;:'",.<>?/]/.test(password), + isValid: /[!@#$%^&*()\-_+=[\]{}|;:'",.<>?/]/.test(password), }, ];