diff --git a/deploy/authentik/blueprints/01-ulti-enrollment.yaml b/deploy/authentik/blueprints/01-ulti-enrollment.yaml
index 4631e0f..7f0eaae 100644
--- a/deploy/authentik/blueprints/01-ulti-enrollment.yaml
+++ b/deploy/authentik/blueprints/01-ulti-enrollment.yaml
@@ -28,20 +28,6 @@ entries:
placeholder_expression: false
order: 0
- - model: authentik_stages_prompt.prompt
- id: ulti-enroll-field-domain-hint
- identifiers:
- name: ulti-enrollment-field-domain-hint
- attrs:
- field_key: domain_hint
- label: Votre adresse sera
- type: static
- required: false
- initial_value: "@ultisuite.fr"
- initial_value_expression: false
- placeholder_expression: false
- order: 1
-
- model: authentik_stages_prompt.prompt
id: ulti-enroll-field-email-sync
identifiers:
@@ -128,7 +114,6 @@ entries:
attrs:
fields:
- !KeyOf ulti-enroll-field-email
- - !KeyOf ulti-enroll-field-domain-hint
- !KeyOf ulti-enroll-field-email-sync
- !KeyOf ulti-enroll-field-password
- !KeyOf ulti-enroll-field-password-repeat
diff --git a/deploy/authentik/blueprints/06-ulti-oidc-logout.yaml b/deploy/authentik/blueprints/06-ulti-oidc-logout.yaml
new file mode 100644
index 0000000..d1f9a83
--- /dev/null
+++ b/deploy/authentik/blueprints/06-ulti-oidc-logout.yaml
@@ -0,0 +1,31 @@
+# UltiSuite — OIDC RP logout : invalide session Authentik + redirect landing
+version: 1
+metadata:
+ name: Ulti OIDC logout redirect
+ labels:
+ blueprints.goauthentik.io/instantiate: "true"
+entries:
+ - model: authentik_stages_redirect.redirectstage
+ id: ulti-logout-redirect
+ identifiers:
+ name: ulti-logout-redirect-suite
+ attrs:
+ mode: static
+ target_static: https://dev.ultispace.fr/
+ keep_context: false
+
+ - model: authentik_flows.flowstagebinding
+ identifiers:
+ target: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
+ stage: !Find [authentik_stages_user_logout.userlogoutstage, [name, default-invalidation-logout]]
+ order: 0
+ attrs:
+ order: 0
+
+ - model: authentik_flows.flowstagebinding
+ identifiers:
+ target: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
+ stage: !KeyOf ulti-logout-redirect
+ order: 10
+ attrs:
+ order: 10
diff --git a/deploy/authentik/blueprints/06-ulti-oidc-logout.yaml.template b/deploy/authentik/blueprints/06-ulti-oidc-logout.yaml.template
new file mode 100644
index 0000000..640ee39
--- /dev/null
+++ b/deploy/authentik/blueprints/06-ulti-oidc-logout.yaml.template
@@ -0,0 +1,31 @@
+# UltiSuite — OIDC RP logout : invalide session Authentik + redirect landing
+version: 1
+metadata:
+ name: Ulti OIDC logout redirect
+ labels:
+ blueprints.goauthentik.io/instantiate: "true"
+entries:
+ - model: authentik_stages_redirect.redirectstage
+ id: ulti-logout-redirect
+ identifiers:
+ name: ulti-logout-redirect-suite
+ attrs:
+ mode: static
+ target_static: {{SUITE_ORIGIN}}/
+ keep_context: false
+
+ - model: authentik_flows.flowstagebinding
+ identifiers:
+ target: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
+ stage: !Find [authentik_stages_user_logout.userlogoutstage, [name, default-invalidation-logout]]
+ order: 0
+ attrs:
+ order: 0
+
+ - model: authentik_flows.flowstagebinding
+ identifiers:
+ target: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
+ stage: !KeyOf ulti-logout-redirect
+ order: 10
+ attrs:
+ order: 10
diff --git a/deploy/authentik/branding/ulti-authentik.css b/deploy/authentik/branding/ulti-authentik.css
index a69b23c..9de7e84 100644
--- a/deploy/authentik/branding/ulti-authentik.css
+++ b/deploy/authentik/branding/ulti-authentik.css
@@ -1,7 +1,861 @@
-/* UltiSuite — masquer le branding Authentik sur flows et portail utilisateur */
+/* =============================================================================
+ * UltiSuite — habillage des flows Authentik (login / signup / recovery / MFA)
+ * -----------------------------------------------------------------------------
+ * Injection : ce fichier est monté sur /web/dist/custom.css dans le conteneur
+ * authentik-server (cf. deploy/docker-compose.yml). Authentik le sert avec
+ * l'attribut `data-inject` :
+ *
+ * Le bundle flow récupère le texte de chaque `link[data-inject]` et l'adopte
+ * (adoptedStyleSheets) dans le shadow root de CHAQUE composant Lit/PatternFly.
+ * => nos sélecteurs `.pf-c-*` atteignent l'intérieur des web components.
+ *
+ * Conséquences (shadow DOM) :
+ * - Les variables custom (`:root`) cascadent via héritage dans tous les roots.
+ * - Les sélecteurs descendants NE TRAVERSENT PAS les frontières de shadow root.
+ * Pour cibler le chrome porté par l'executor on utilise `:host([flowslug=…])`
+ * (l'hôte du shadow root de l'executor est ``).
+ * - Les éléments internes d'une étape (formulaire, champs, bouton) vivent dans
+ * le shadow root `` : on les style globalement (look partagé).
+ *
+ * Carte de la structure (flow login `default-authentication-flow`) :
+ * ak-flow-executor[flowslug] (shadow #1)
+ * .pf-c-background-image ← fond plein écran
+ * .pf-c-login.stacked > .ak-login-container
+ * .pf-c-login__main ← LA CARTE
+ * .pf-c-login__main-header.pf-c-brand.ak-brand > img ← logo
+ * ak-stage-identification (shadow #2)
+ * header.pf-c-login__main-header > h1.pf-c-title ← titre
+ * .pf-c-login__main-body > form.pf-c-form
+ * ak-form-element.pf-c-form__group (shadow #3 : label) + input.pf-c-form-control
+ * .pf-c-form__group.pf-m-action > button.pf-c-button.pf-m-primary
+ * footer.pf-c-login__main-footer
+ * .pf-c-login__main-footer-band > p > a#enroll ← "Créer un compte"
+ * .pf-c-login__footer ← "Powered by authentik" (masqué)
+ * ========================================================================== */
-ak-branding-footer,
+/* ---- Angle animé pour les anneaux arc-en-ciel (carte + bouton) ---------- */
+@property --ulti-ring-angle {
+ syntax: "";
+ inherits: true;
+ initial-value: 145deg;
+}
+
+/* ---- Jetons de design (repris de .ultimail-login du frontend) ----------- */
+:root {
+ --ulti-bg: #f7f8fc;
+ --ulti-line: rgba(21, 24, 30, 0.07);
+ --ulti-glow-a: #4f6df5;
+ --ulti-glow-b: #9a5cf0;
+ --ulti-glow-c: #1fb6c9;
+ --ulti-card-glass: rgba(255, 255, 255, 0.62);
+ --ulti-card-border: rgba(21, 24, 30, 0.1);
+ --ulti-card-highlight: rgba(255, 255, 255, 0.72);
+ --ulti-text: #3c4043;
+ --ulti-text-strong: #202124;
+ --ulti-text-muted: #5f6368;
+ --ulti-link: #1a73e8;
+ --ulti-input-bg: #ffffff;
+ --ulti-input-border: rgba(21, 24, 30, 0.16);
+ --ulti-rainbow: conic-gradient(
+ from var(--ulti-ring-angle),
+ #1a73e8,
+ #34a853,
+ #fbbc04,
+ #ea4335,
+ #1a73e8
+ );
+ /* Remplace l'accent corail Authentik (#fd4b2d) — utilisé pour les scrollbars. */
+ --ulti-scrollbar-thumb: color-mix(in srgb, var(--ulti-text-muted) 55%, transparent);
+ --ulti-scrollbar-thumb-hover: color-mix(in srgb, var(--ulti-text-muted) 75%, transparent);
+ --ak-accent: var(--ulti-scrollbar-thumb);
+ /* Boutons primaires — gris neutre */
+ --ulti-btn-primary-bg: #f4f4f5;
+ --ulti-btn-primary-fg: #18181b;
+ --ulti-btn-primary-bg-hover: #e4e4e7;
+ --ulti-btn-primary-border: color-mix(in srgb, var(--ulti-text-strong) 14%, transparent);
+ --ulti-btn-primary-border-width: 2px;
+ --ulti-btn-primary-border-hover: color-mix(in srgb, var(--ulti-text-muted) 42%, transparent);
+ --ulti-btn-primary-shadow: none;
+ --ulti-btn-primary-shadow-hover: none;
+ --ulti-btn-primary-ring: color-mix(in srgb, var(--ulti-text-muted) 35%, transparent);
+ --ulti-link-ui: var(--ulti-text-muted);
+ --ulti-link-ui-hover: var(--ulti-text);
+ --pf-global--link--Color: var(--ulti-link-ui);
+ --pf-global--link--Color--hover: var(--ulti-link-ui-hover);
+ --pf-global--link--Color--light: var(--ulti-link-ui);
+ --pf-global--link--Color--dark: var(--ulti-link-ui-hover);
+ --ulti-card-backdrop: blur(16px) saturate(1.2);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --ulti-bg: #0b0d12;
+ --ulti-line: rgba(238, 240, 246, 0.08);
+ --ulti-glow-a: #5d7bff;
+ --ulti-glow-b: #a86bff;
+ --ulti-glow-c: #2cc8dc;
+ --ulti-card-glass: rgba(30, 30, 33, 0.96);
+ --ulti-card-border: rgba(255, 255, 255, 0.1);
+ --ulti-card-highlight: rgba(255, 255, 255, 0.06);
+ --ulti-card-backdrop: blur(12px);
+ --ulti-text: #e8eaed;
+ --ulti-text-strong: #ffffff;
+ --ulti-text-muted: #bdc1c6;
+ --ulti-link: #8ab4f8;
+ --ulti-input-bg: rgba(0, 0, 0, 0.25);
+ --ulti-input-border: rgba(255, 255, 255, 0.14);
+ --ulti-scrollbar-thumb: color-mix(in srgb, var(--ulti-text-muted) 50%, transparent);
+ --ulti-scrollbar-thumb-hover: color-mix(in srgb, var(--ulti-text-muted) 70%, transparent);
+ --ak-accent: var(--ulti-scrollbar-thumb);
+ --ulti-btn-primary-bg: #18181b;
+ --ulti-btn-primary-fg: var(--ulti-text-strong);
+ --ulti-btn-primary-bg-hover: #242428;
+ --ulti-btn-primary-border-width: 1px;
+ --ulti-btn-primary-border: rgba(255, 255, 255, 0.32);
+ --ulti-btn-primary-border-hover: rgba(255, 255, 255, 0.44);
+ --ulti-btn-primary-shadow: none;
+ --ulti-btn-primary-shadow-hover: none;
+ --ulti-btn-primary-ring: color-mix(in srgb, var(--ulti-text-muted) 42%, transparent);
+ --ulti-link-ui: color-mix(in srgb, var(--ulti-text-muted) 92%, var(--ulti-text));
+ --ulti-link-ui-hover: var(--ulti-text-strong);
+ }
+}
+
+/* Scrollbars — Authentik défaut utilise var(--ak-accent) corail sur le thumb. */
+html {
+ scrollbar-width: thin;
+ scrollbar-color: var(--ulti-scrollbar-thumb) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ background-color: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: var(--ulti-scrollbar-thumb);
+ border-radius: 9999px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: var(--ulti-scrollbar-thumb-hover);
+}
+
+::-webkit-scrollbar-track,
+::-webkit-scrollbar-corner {
+ background-color: transparent;
+}
+
+/* ===========================================================================
+ * 1. FOND — remplace la photo de route Authentik par l'aurore UltiSuite
+ * ======================================================================== */
+html,
+body {
+ background: var(--ulti-bg) !important;
+ overflow-x: hidden;
+ min-height: 100dvh;
+}
+
+/* PatternFly laisse .pf-c-background-image à hauteur 0 en mode stacked — on la fixe
+ * en plein écran pour que l'aurore / la grille soient visibles. */
+.pf-c-background-image {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ width: 100%;
+ height: 100%;
+ min-height: 100dvh;
+ overflow: hidden;
+ pointer-events: none;
+ background-color: var(--ulti-bg) !important;
+ background-image:
+ radial-gradient(
+ 40rem 36rem at 18% 8%,
+ color-mix(in oklab, var(--ulti-glow-a) 60%, transparent),
+ transparent 58%
+ ),
+ radial-gradient(
+ 36rem 34rem at 84% 6%,
+ color-mix(in oklab, var(--ulti-glow-b) 55%, transparent),
+ transparent 60%
+ ),
+ radial-gradient(
+ 40rem 36rem at 50% 104%,
+ color-mix(in oklab, var(--ulti-glow-c) 55%, transparent),
+ transparent 60%
+ ),
+ radial-gradient(
+ 60% 50% at 50% 42%,
+ color-mix(in oklab, var(--ulti-glow-a) 14%, transparent),
+ transparent 70%
+ ) !important;
+ background-repeat: no-repeat !important;
+ background-size: cover !important;
+ filter: none !important;
+ animation: ulti-aurora-drift 28s ease-in-out infinite alternate;
+}
+
+/* Orbe flottante A (cf. .ultimail-login-orb--a) */
+.pf-c-background-image::before {
+ content: "";
+ position: absolute;
+ z-index: 0;
+ width: 40rem;
+ height: 40rem;
+ top: -16rem;
+ left: -12rem;
+ border-radius: 9999px;
+ pointer-events: none;
+ background: radial-gradient(
+ circle at 30% 30%,
+ color-mix(in oklab, var(--ulti-glow-a) 85%, transparent),
+ transparent 65%
+ );
+ filter: blur(90px);
+ opacity: 0.5;
+ will-change: transform;
+ animation: ulti-orb-drift-a 26s ease-in-out infinite alternate;
+}
+
+@media (prefers-color-scheme: dark) {
+ .pf-c-background-image::before {
+ opacity: 0.42;
+ }
+}
+
+@media (max-width: 639px) {
+ .pf-c-background-image::before {
+ width: 28rem;
+ height: 28rem;
+ top: -10rem;
+ left: -8rem;
+ filter: blur(70px);
+ }
+
+ .pf-c-background-image {
+ background-image:
+ radial-gradient(
+ 120% 80% at 10% 0%,
+ color-mix(in oklab, var(--ulti-glow-a) 55%, transparent),
+ transparent 55%
+ ),
+ radial-gradient(
+ 100% 70% at 95% 8%,
+ color-mix(in oklab, var(--ulti-glow-b) 50%, transparent),
+ transparent 58%
+ ),
+ radial-gradient(
+ 120% 80% at 50% 100%,
+ color-mix(in oklab, var(--ulti-glow-c) 50%, transparent),
+ transparent 60%
+ ),
+ radial-gradient(
+ 90% 60% at 50% 45%,
+ color-mix(in oklab, var(--ulti-glow-a) 16%, transparent),
+ transparent 72%
+ ) !important;
+ }
+}
+
+/* Les filtres SVG / images PatternFly du fond ne servent plus. */
+.pf-c-background-image > * {
+ display: none !important;
+}
+
+/* Grille fine masquée par un dégradé radial (cf. .ultimail-login-backdrop). */
+.pf-c-background-image::after {
+ content: "";
+ position: absolute;
+ z-index: 1;
+ inset: 0;
+ pointer-events: none;
+ background-image:
+ linear-gradient(to right, var(--ulti-line) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--ulti-line) 1px, transparent 1px);
+ background-size: 56px 56px;
+ -webkit-mask-image: radial-gradient(
+ ellipse 80% 70% at 50% 42%,
+ black 28%,
+ transparent 80%
+ );
+ mask-image: radial-gradient(
+ ellipse 80% 70% at 50% 42%,
+ black 28%,
+ transparent 80%
+ );
+}
+
+/* ===========================================================================
+ * 2. MISE EN PAGE — carte centrée, chrome transparent devant le fond fixe
+ * ======================================================================== */
+.pf-c-page__drawer,
+.pf-c-drawer,
+.pf-c-drawer__main,
+.pf-c-drawer__content,
+.pf-c-drawer__body {
+ position: relative;
+ z-index: 1;
+ background: transparent !important;
+}
+
+.pf-c-login.stacked {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 100dvh !important;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
+ padding: max(1rem, env(safe-area-inset-top)) 1rem max(1rem, env(safe-area-inset-bottom)) !important;
+}
+
+.pf-c-login.stacked .ak-login-container {
+ width: 100%;
+ max-width: min(26rem, 100%);
+ margin-inline: auto;
+ flex: 0 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.pf-c-login__main {
+ position: relative;
+ isolation: isolate;
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 100%;
+ /* visible : le hover du bouton (translateY + ombre) ne doit pas être rogné */
+ overflow: visible;
+ padding: 1.5rem 2rem 1.25rem !important;
+ border-radius: 2.25rem !important;
+ background: var(--ulti-card-glass) !important;
+ border: 1px solid var(--ulti-card-border) !important;
+ box-shadow:
+ inset 0 1px 0 var(--ulti-card-highlight),
+ 0 16px 44px -16px rgba(0, 0, 0, 0.28) !important;
+ -webkit-backdrop-filter: var(--ulti-card-backdrop, blur(16px) saturate(1.2));
+ backdrop-filter: var(--ulti-card-backdrop, blur(16px) saturate(1.2));
+}
+
+/* Anneau arc-en-ciel confiné au bord via masque (padding ring). */
+.pf-c-login__main::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ border-radius: inherit;
+ padding: 2.5px;
+ background: var(--ulti-rainbow);
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ mask-composite: exclude;
+ pointer-events: none;
+ animation: ulti-ring-spin 9s linear infinite;
+}
+
+/* Logo UltiSuite (header executor) — variante thème via content:url(). */
+.pf-c-login__main-header.pf-c-brand,
+.ak-brand {
+ padding-top: 0.25rem !important;
+ padding-bottom: 0.75rem !important;
+ margin-bottom: 0 !important;
+}
+
+.pf-c-login__main-header.pf-c-brand img,
+.ak-brand img,
+.pf-c-brand img {
+ max-height: 52px;
+ width: auto;
+ max-width: min(280px, 78vw);
+ content: url("/auth/static/dist/assets/branding/ultisuite-logo-light.png");
+}
+
+@media (prefers-color-scheme: dark) {
+ .pf-c-login__main-header.pf-c-brand img,
+ .ak-brand img,
+ .pf-c-brand img {
+ content: url("/auth/static/dist/assets/branding/ultisuite-logo-dark.png");
+ }
+}
+
+/* ===========================================================================
+ * 3. TITRE + DESCRIPTION DE L'ÉTAPE
+ * ======================================================================== */
+.pf-c-login__main-body,
+.pf-c-login__main-header {
+ background: transparent !important;
+}
+
+/* Titre d'étape (identification, mot de passe, etc.) — pas le bandeau logo */
+.pf-c-login__main-header:not(.pf-c-brand):not(.ak-brand) {
+ padding: 0 0 1rem !important;
+ margin: 0 !important;
+}
+
+.pf-c-title {
+ text-align: center;
+ color: var(--ulti-text-strong) !important;
+ font-weight: 600 !important;
+ margin: 0 !important;
+ line-height: 1.35 !important;
+}
+
+.pf-c-title.pf-m-3xl {
+ font-size: 1.125rem !important;
+}
+
+/* Bandeau utilisateur (avatar + « Pas vous ? ») sur l'étape mot de passe */
+.pf-c-login__main-body .pf-c-login__main-header,
+.pf-c-form .pf-c-login__main-header {
+ padding: 0 0 1rem !important;
+}
+
+/* ===========================================================================
+ * 4. FORMULAIRE — labels, champs (.pf-c-form-control)
+ * ======================================================================== */
+.pf-c-form {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ column-gap: 0.75rem;
+ row-gap: 0;
+ align-items: center;
+}
+
+.pf-c-form > ak-form-static,
+.pf-c-form > input,
+.pf-c-form > ak-flow-input-password,
+.pf-c-form > ak-form-element,
+.pf-c-form > .pf-c-form__group:not(.pf-m-action),
+.pf-c-form > p {
+ grid-column: 1 / -1;
+}
+
+/* Mot de passe oublié + bouton : même ligne, lien à gauche / bouton à droite */
+.pf-c-form > a:has(+ .pf-c-form__group.pf-m-action) {
+ grid-column: 1;
+ justify-self: start;
+ align-self: center;
+ margin: 0.175rem 0 0.25rem !important;
+}
+
+.pf-c-form > a + .pf-c-form__group.pf-m-action {
+ grid-column: 2;
+ justify-self: end;
+ align-self: center;
+ margin: 0.175rem 0 0.25rem !important;
+}
+
+/* Bouton seul (sans lien juste avant) */
+.pf-c-form > .pf-c-form__group.pf-m-action {
+ grid-column: 2;
+ margin: 0.175rem 0 0.25rem !important;
+}
+
+.pf-c-form__group {
+ margin-bottom: 0.5rem !important;
+}
+
+/* Liens auxiliaires (mot de passe oublié, « Pas vous ? », inscription…) */
+.pf-c-login__main a,
+.pf-c-form a,
+ak-form-static a {
+ color: var(--ulti-link-ui) !important;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ text-decoration: underline;
+ text-underline-offset: 0.15em;
+ transition: color 0.18s ease;
+}
+
+.pf-c-login__main a:hover,
+.pf-c-form a:hover,
+ak-form-static a:hover {
+ color: var(--ulti-link-ui-hover) !important;
+}
+
+.pf-c-form__group.pf-m-action {
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
+ padding-top: 0 !important;
+ padding-bottom: 0 !important;
+ overflow: visible !important;
+ display: var(--ulti-action-group-display, block);
+ justify-content: var(--ulti-action-group-justify, normal);
+}
+
+.pf-c-login__main-body {
+ padding: 0 !important;
+ overflow: visible !important;
+}
+
+.pf-c-form__label-text {
+ color: var(--ulti-text-muted) !important;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.pf-c-form__label-required {
+ color: var(--ulti-link) !important;
+}
+
+.pf-c-form-control {
+ height: 2.6rem;
+ padding: 0 0.85rem !important;
+ font-size: 0.9rem !important;
+ color: var(--ulti-text) !important;
+ background: var(--ulti-input-bg) !important;
+ border: 1px solid var(--ulti-input-border) !important;
+ border-radius: 0.6rem !important;
+ box-shadow: none !important;
+ transition:
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.pf-c-form-control:hover {
+ border-color: color-mix(in srgb, var(--ulti-link) 45%, var(--ulti-input-border)) !important;
+}
+
+.pf-c-form-control:focus,
+.pf-c-form-control:focus-visible {
+ border-color: var(--ulti-link) !important;
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ulti-link) 25%, transparent) !important;
+ outline: none !important;
+}
+
+/* ===========================================================================
+ * 4b. TEXTES D'AIDE / DESCRIPTION D'ÉTAPE
+ * -----------------------------------------------------------------------------
+ * Les étapes prompt/identification injectent des paragraphes bruts dans le
+ * corps (description de l'étape, suffixe de domaine "@ultisuite.fr", aide de
+ * champ). Sans règle ils héritent de la couleur PatternFly par défaut — peu
+ * lisible en sombre. On les aligne sur le texte atténué partagé.
+ * (Recovery : description "Enter the email…". Enrollment : "@ultisuite.fr".)
+ * ======================================================================== */
+.pf-c-login__main-body > p,
+.pf-c-form > p,
+.pf-c-form__helper-text {
+ color: var(--ulti-text-muted) !important;
+ font-size: 0.85rem;
+ line-height: 1.45;
+}
+
+/* Suffixe de domaine d'enrollment : rapproché du champ e-mail qu'il complète. */
+.pf-c-form > p {
+ margin-top: -0.35rem !important;
+ margin-bottom: 0.125rem !important;
+}
+
+/* ===========================================================================
+ * 4c. INSCRIPTION (ulti-enrollment) — e-mail suffixé, mots de passe
+ * ======================================================================== */
+
+ak-flow-executor[flowslug="ulti-enrollment"] ak-form-element:has(input[name="password"]).pf-c-form__group,
+ak-flow-executor[flowslug="ulti-enrollment"] ak-form-element:has(input[name="password"]) .pf-c-form__group {
+ margin-bottom: 0.175rem !important;
+}
+
+ak-flow-executor[flowslug="ulti-enrollment"] .ulti-password-strength {
+ grid-column: 1 / -1;
+ margin: 0.125rem 0 0.075rem !important;
+}
+
+ak-flow-executor[flowslug="ulti-enrollment"] ak-form-element:has(input[name="password_repeat"]).pf-c-form__group,
+ak-flow-executor[flowslug="ulti-enrollment"] ak-form-element:has(input[name="password_repeat"]) .pf-c-form__group {
+ margin-bottom: 0.25rem !important;
+}
+
+ak-flow-executor[flowslug="ulti-enrollment"] .ulti-password-match {
+ grid-column: 1 / -1;
+ margin: 0.075rem 0 0.175rem !important;
+}
+
+ak-flow-executor[flowslug="ulti-enrollment"] .ulti-email-availability {
+ grid-column: 1 / -1;
+ margin: 0.075rem 0 0.175rem !important;
+ font-size: 0.75rem;
+ line-height: 1.4;
+}
+
+.ulti-email-availability.muted,
+.ulti-email-availability.checking {
+ color: var(--ulti-text-muted) !important;
+}
+
+.ulti-email-availability.ok {
+ color: #10b981 !important;
+}
+
+.ulti-email-availability.bad {
+ color: #ef4444 !important;
+}
+
+.pf-c-button.pf-m-primary:disabled,
+.pf-c-button.pf-m-primary[disabled] {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.ulti-email-input-group {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+}
+
+.ulti-email-input-group input[name="username"] {
+ flex: 1 1 auto;
+ min-width: 0;
+ border-top-right-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+ border-right: none !important;
+}
+
+.ulti-email-suffix {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ padding: 0 0.75rem;
+ border: 1px solid var(--ulti-input-border);
+ border-left: none;
+ border-radius: 0 0.375rem 0.375rem 0;
+ background: color-mix(in srgb, var(--ulti-input-bg) 90%, var(--ulti-text-muted));
+ color: var(--ulti-text-muted);
+ font-size: 0.9375rem;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.ulti-password-wrap {
+ position: relative;
+ width: 100%;
+}
+
+.ulti-password-input {
+ width: 100%;
+ padding-right: 2.5rem !important;
+}
+
+.ulti-password-toggle {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 2.5rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--ulti-text-muted);
+ cursor: pointer;
+ border-radius: 0 0.375rem 0.375rem 0;
+ transition: color 0.15s ease;
+}
+
+.ulti-password-toggle:hover {
+ color: var(--ulti-text-strong);
+}
+
+.ulti-password-toggle:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--ulti-btn-primary-ring);
+}
+
+.ulti-password-strength-bars {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.ulti-password-strength-bars span {
+ flex: 1;
+ height: 0.25rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--ulti-text-muted) 22%, transparent);
+ transition: background-color 0.15s ease;
+}
+
+.ulti-password-strength-bars span.active.weak {
+ background: #ef4444;
+}
+
+.ulti-password-strength-bars span.active.fair {
+ background: #f59e0b;
+}
+
+.ulti-password-strength-bars span.active.good {
+ background: #10b981;
+}
+
+.ulti-password-strength-bars span.active.strong {
+ background: #059669;
+}
+
+.ulti-password-strength-label {
+ margin: 0.175rem 0 0 !important;
+ font-size: 0.75rem;
+ color: var(--ulti-text-muted) !important;
+}
+
+.ulti-password-strength-label strong {
+ color: var(--ulti-text-strong);
+ font-weight: 600;
+}
+
+.ulti-password-match {
+ font-size: 0.75rem;
+}
+
+.ulti-password-match.ok {
+ color: #10b981 !important;
+}
+
+.ulti-password-match.bad {
+ color: #ef4444 !important;
+}
+
+/* ===========================================================================
+ * 5. BOUTONS PRIMAIRES — gris neutre, bordure 2px
+ * ======================================================================== */
+
+ak-flow-executor[flowslug="default-authentication-flow"],
+ak-flow-executor[flowslug="ulti-enrollment"],
+ak-flow-executor[flowslug="ulti-recovery"] {
+ --ulti-action-group-display: flex;
+ --ulti-action-group-justify: flex-end;
+ --ulti-primary-btn-width: auto;
+ --ulti-primary-btn-height: 2.625rem;
+ --ulti-primary-btn-min-height: 2.625rem;
+ --ulti-primary-btn-padding: 0 1rem;
+ --ulti-primary-btn-radius: 0.6rem;
+ --ulti-primary-btn-font-size: 0.9375rem;
+ --ulti-primary-btn-weight: 600;
+}
+
+.pf-c-button.pf-m-primary {
+ display: var(--ulti-primary-btn-display, inline-flex);
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ width: var(--ulti-primary-btn-width, auto);
+ height: var(--ulti-primary-btn-height, auto);
+ min-height: var(--ulti-primary-btn-min-height, 2.25rem);
+ margin: 0 !important;
+ padding: var(--ulti-primary-btn-padding, 0.4375rem 0.875rem) !important;
+ border: var(--ulti-btn-primary-border-width, 2px) solid var(--ulti-btn-primary-border, transparent) !important;
+ border-radius: var(--ulti-primary-btn-radius, 0.375rem) !important;
+ font-size: var(--ulti-primary-btn-font-size, 0.9375rem) !important;
+ font-weight: var(--ulti-primary-btn-weight, 600) !important;
+ line-height: 1 !important;
+ letter-spacing: normal;
+ color: var(--ulti-btn-primary-fg) !important;
+ text-shadow: none;
+ background-color: var(--ulti-btn-primary-bg) !important;
+ background-image: none !important;
+ box-shadow: var(--ulti-btn-primary-shadow, none) !important;
+ transition:
+ background-color 0.18s ease,
+ border-color 0.18s ease,
+ opacity 0.15s ease;
+}
+
+.pf-c-button.pf-m-primary:hover {
+ background-color: var(--ulti-btn-primary-bg-hover) !important;
+ border-color: var(--ulti-btn-primary-border-hover) !important;
+}
+
+.pf-c-button.pf-m-primary:active {
+ opacity: 0.94;
+}
+
+.pf-c-button.pf-m-primary:focus-visible {
+ outline: none !important;
+ border-color: var(--ulti-btn-primary-border-hover) !important;
+ box-shadow: 0 0 0 3px var(--ulti-btn-primary-ring) !important;
+}
+
+.pf-c-form__group.pf-m-action .pf-c-button.pf-m-primary {
+ width: var(--ulti-primary-btn-width, 100%) !important;
+ max-width: 100%;
+}
+
+/* Overlay de chargement Authentik (après « Continuer ») — aligné sur la carte. */
+ak-loading-overlay {
+ position: absolute !important;
+ inset: 0 !important;
+ top: 0 !important;
+ left: 0 !important;
+ width: 100% !important;
+ height: 100% !important;
+ margin: 0 !important;
+ border-radius: inherit !important;
+ background: color-mix(in srgb, var(--ulti-card-glass) 82%, transparent) !important;
+ -webkit-backdrop-filter: blur(6px) saturate(1.2);
+ backdrop-filter: blur(6px) saturate(1.2);
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ z-index: 10;
+}
+
+ak-loading-overlay .pf-c-empty-state {
+ padding: 0 !important;
+ background: transparent !important;
+}
+
+ak-loading-overlay .pf-c-spinner {
+ --pf-c-spinner--Color: var(--ulti-link);
+}
+
+/* ===========================================================================
+ * 6. PIED DE CARTE — bande "Créer un compte" / liens auxiliaires
+ * ======================================================================== */
+.pf-c-login__main-footer {
+ background: transparent !important;
+}
+
+.pf-c-login__main-footer-band {
+ margin-top: 0.5rem !important;
+ padding: 0.875rem 0 0.25rem !important;
+ background: transparent !important;
+ border: 0 !important;
+ text-align: center;
+}
+
+/* Liens auxiliaires (mot de passe oublié, inscription, etc.) */
+.pf-c-login__main-footer-links {
+ margin: 0.15rem 0 0 !important;
+ padding: 0 !important;
+ list-style: none !important;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.15rem;
+}
+
+.pf-c-login__main-footer-links li {
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+.pf-c-login__main-footer-band-item {
+ color: var(--ulti-text-muted) !important;
+ font-size: 0.85rem;
+}
+
+/* ===========================================================================
+ * 7. MASQUAGE DU BRANDING AUTHENTIK
+ * ======================================================================== */
.pf-c-login__footer,
+ak-branding-footer,
.pf-c-login__footer-text,
a[href*="goauthentik.io"],
a[href*="authentik.io"] {
@@ -11,13 +865,276 @@ a[href*="authentik.io"] {
overflow: hidden !important;
}
-ak-brand-logo img,
-.pf-c-brand img {
- max-height: 48px;
- width: auto;
- max-width: min(280px, 80vw);
-}
-
ak-flow-executor::part(footer) {
display: none !important;
}
+
+/* ===========================================================================
+ * 8. MODE EMBARQUÉ — surfaces Authentik intégrées en iframe dans UltiSuite
+ * -----------------------------------------------------------------------------
+ * Deux familles de surfaces sont embarquées dans le panneau « réglages »
+ * d'UltiSuite (chrome Authentik masqué → seul le panneau utile reste visible) :
+ *
+ * A) La SPA user-settings : `/auth/if/user/#/settings;{"page":""}`
+ * Hôtes : → (shadow)
+ * → (shadow) → (shadow) .
+ * Pages deep-linkables via le hash (ex. `#/settings;{"page":"page-mfa"}`) :
+ * • MFA / authentificateurs : page-mfa →
+ * • Sessions actives : page-sessions →
+ * (autres : page-details, page-consents, page-sources, page-tokens)
+ *
+ * B) Les flows de configuration MFA, ouverts en plein écran depuis le menu
+ * « Enroll » de la page MFA (configure flow → ) :
+ * • default-authenticator-totp-setup
+ * • default-authenticator-webauthn-setup
+ * • default-authenticator-static-setup
+ * • default-password-change (bouton « Change password », page-details)
+ * • ulti-post-migration-security (blueprint 04 — sécuriser le compte)
+ *
+ * HYPOTHÈSE DE SCOPING (assumée, cf. brief) : la SPA user-settings n'est JAMAIS
+ * montrée en standalone aux utilisateurs finaux — uniquement embarquée dans
+ * UltiSuite. On dépouille donc son chrome INCONDITIONNELLEMENT, mais en scopant
+ * aux hôtes de l'INTERFACE UTILISATEUR (`ak-interface-user-presentation`,
+ * `ak-user-settings`, `ak-user-*-list/mfa`) afin de NE PAS toucher :
+ * - l'interface ADMIN (`if/admin`, hôtes `ak-interface-admin*`) ;
+ * - les FLOWS de login/signup/recovery (carte + branding conservés).
+ * Les flows de config MFA listés en B) ne sont atteints QUE depuis les réglages
+ * (le login garde son slug `default-authentication-flow`) → strip sûr.
+ *
+ * NOTE Firefox : la règle 8.3 (masquage de la nav d'onglets) repose sur
+ * `:host-context()`, non implémenté par Firefox. Cibles principales (Chrome,
+ * Edge, WebView Android, WKWebView/WebKitGTK de Tauri) OK ; sous Firefox la
+ * barre d'onglets verticale resterait visible (dégradation, pas de casse).
+ * ======================================================================== */
+
+/* ---- 8.1 SPA : masquage du chrome global (header, fond, marges) --------- */
+/* Hôte = (porte le shadow contenant header
+ * + fond + drawer). Scope strict : n'atteint pas l'admin ni les flows. */
+:host(ak-interface-user-presentation) .pf-c-page__header,
+:host(ak-interface-user-presentation) .background-wrapper {
+ display: none !important;
+}
+
+:host(ak-interface-user-presentation) .pf-c-page,
+:host(ak-interface-user-presentation) .pf-c-page__drawer,
+:host(ak-interface-user-presentation) .pf-c-drawer,
+:host(ak-interface-user-presentation) .pf-c-drawer__main,
+:host(ak-interface-user-presentation) .pf-c-drawer__content,
+:host(ak-interface-user-presentation) .pf-c-drawer__body {
+ background: transparent !important;
+}
+
+:host(ak-interface-user-presentation) .pf-c-page__main {
+ margin: 0 !important;
+ padding: 0 !important;
+}
+
+/* ---- 8.2 SPA : page de réglages à plat (transparente, sans marges) ------ */
+:host(ak-user-settings) .pf-c-page,
+:host(ak-user-settings) .pf-c-page__main {
+ background: transparent !important;
+ padding: 0 !important;
+ margin: 0 !important;
+}
+
+:host(ak-user-settings) .pf-c-page__main-section {
+ padding: 0 !important;
+ background: transparent !important;
+}
+
+/* La carte qui enveloppe chaque panneau (MFA / sessions) devient le panneau
+ * UltiSuite lui-même : fond/ombre/bordure retirés pour fondre dans l'iframe. */
+:host(ak-user-settings) .pf-c-card {
+ background: transparent !important;
+ box-shadow: none !important;
+ border: 0 !important;
+ border-radius: 0 !important;
+}
+
+:host(ak-user-settings) .pf-c-card__body {
+ padding: 0 !important;
+}
+
+/* ---- 8.3 SPA : masquage de la barre d'onglets verticale (cf. NOTE) ------ */
+/* est générique (réutilisé par l'admin) : on le restreint au seul
+ * réglage utilisateur via :host-context(ak-user-settings). Le panneau actif
+ * (slot page-*) reste affiché ; seule la nav latérale disparaît. */
+:host-context(ak-user-settings) .pf-c-tabs.pf-m-vertical {
+ display: none !important;
+}
+
+/* ---- 8.4 SPA : look UltiSuite des listes MFA & sessions ----------------- */
+/* Scopé aux hôtes propres à l'interface utilisateur (jamais en admin/flows). */
+:host(ak-user-settings-mfa) .pf-c-table,
+:host(ak-user-session-list) .pf-c-table {
+ background: transparent !important;
+ color: var(--ulti-text) !important;
+}
+
+:host(ak-user-settings-mfa) .pf-c-table th,
+:host(ak-user-settings-mfa) .pf-c-table td,
+:host(ak-user-session-list) .pf-c-table th,
+:host(ak-user-session-list) .pf-c-table td {
+ color: var(--ulti-text) !important;
+ border-color: var(--ulti-line) !important;
+}
+
+:host(ak-user-settings-mfa) .pf-c-table th,
+:host(ak-user-session-list) .pf-c-table th {
+ color: var(--ulti-text-muted) !important;
+}
+
+:host(ak-user-settings-mfa) .pf-c-toolbar,
+:host(ak-user-session-list) .pf-c-toolbar {
+ background: transparent !important;
+ padding-inline: 0 !important;
+}
+
+/* Boutons réglages / MFA */
+:host(ak-user-settings-mfa) .pf-c-dropdown__toggle.pf-m-primary,
+:host(ak-user-settings) .pf-c-button.pf-m-primary,
+:host(ak-user-settings-mfa) .pf-c-button.pf-m-primary,
+:host(ak-user-session-list) .pf-c-button.pf-m-primary {
+ border-radius: 0.375rem !important;
+ padding: 0.4375rem 0.875rem !important;
+}
+
+:host(ak-user-settings-mfa) .pf-c-dropdown__toggle.pf-m-primary {
+ border: 0 !important;
+}
+
+/* ---- 8.5 Flows de configuration MFA / sécurité : chrome dépouillé ------- */
+/* Mêmes effets que l'ancien stub, appliqués aux VRAIS slugs embarqués. */
+:host([flowslug="default-authenticator-totp-setup"]) .pf-c-background-image,
+:host([flowslug="default-authenticator-totp-setup"]) .pf-c-login__footer,
+:host([flowslug="default-authenticator-totp-setup"]) .pf-c-login__main-header.pf-c-brand,
+:host([flowslug="default-authenticator-webauthn-setup"]) .pf-c-background-image,
+:host([flowslug="default-authenticator-webauthn-setup"]) .pf-c-login__footer,
+:host([flowslug="default-authenticator-webauthn-setup"]) .pf-c-login__main-header.pf-c-brand,
+:host([flowslug="default-authenticator-static-setup"]) .pf-c-background-image,
+:host([flowslug="default-authenticator-static-setup"]) .pf-c-login__footer,
+:host([flowslug="default-authenticator-static-setup"]) .pf-c-login__main-header.pf-c-brand,
+:host([flowslug="default-password-change"]) .pf-c-background-image,
+:host([flowslug="default-password-change"]) .pf-c-login__footer,
+:host([flowslug="default-password-change"]) .pf-c-login__main-header.pf-c-brand,
+:host([flowslug="ulti-post-migration-security"]) .pf-c-background-image,
+:host([flowslug="ulti-post-migration-security"]) .pf-c-login__footer,
+:host([flowslug="ulti-post-migration-security"]) .pf-c-login__main-header.pf-c-brand {
+ display: none !important;
+}
+
+:host([flowslug="default-authenticator-totp-setup"]) .pf-c-login.stacked,
+:host([flowslug="default-authenticator-webauthn-setup"]) .pf-c-login.stacked,
+:host([flowslug="default-authenticator-static-setup"]) .pf-c-login.stacked,
+:host([flowslug="default-password-change"]) .pf-c-login.stacked,
+:host([flowslug="ulti-post-migration-security"]) .pf-c-login.stacked {
+ background: transparent !important;
+}
+
+:host([flowslug="default-authenticator-totp-setup"]) .pf-c-login.stacked .ak-login-container,
+:host([flowslug="default-authenticator-webauthn-setup"]) .pf-c-login.stacked .ak-login-container,
+:host([flowslug="default-authenticator-static-setup"]) .pf-c-login.stacked .ak-login-container,
+:host([flowslug="default-password-change"]) .pf-c-login.stacked .ak-login-container,
+:host([flowslug="ulti-post-migration-security"]) .pf-c-login.stacked .ak-login-container {
+ max-width: none !important;
+ margin: 0 !important;
+}
+
+:host([flowslug="default-authenticator-totp-setup"]) .pf-c-login__main,
+:host([flowslug="default-authenticator-webauthn-setup"]) .pf-c-login__main,
+:host([flowslug="default-authenticator-static-setup"]) .pf-c-login__main,
+:host([flowslug="default-password-change"]) .pf-c-login__main,
+:host([flowslug="ulti-post-migration-security"]) .pf-c-login__main {
+ max-width: none !important;
+ padding: 0 !important;
+ border: 0 !important;
+ border-radius: 0 !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+}
+
+:host([flowslug="default-authenticator-totp-setup"]) .pf-c-login__main::before,
+:host([flowslug="default-authenticator-webauthn-setup"]) .pf-c-login__main::before,
+:host([flowslug="default-authenticator-static-setup"]) .pf-c-login__main::before,
+:host([flowslug="default-password-change"]) .pf-c-login__main::before,
+:host([flowslug="ulti-post-migration-security"]) .pf-c-login__main::before {
+ display: none !important;
+}
+
+/* ===========================================================================
+ * 9. ANIMATIONS / ACCESSIBILITÉ
+ * ======================================================================== */
+@keyframes ulti-ring-spin {
+ to {
+ --ulti-ring-angle: 505deg;
+ }
+}
+
+@keyframes ulti-aurora-drift {
+ from {
+ background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%;
+ }
+ to {
+ background-size: 112% 108%, 108% 112%, 110% 105%, 106% 104%;
+ }
+}
+
+@keyframes ulti-orb-drift-a {
+ from {
+ transform: translate3d(0, 0, 0) scale(1);
+ }
+ to {
+ transform: translate3d(6rem, 4rem, 0) scale(1.12);
+ }
+}
+
+/* Évite le rognage du bouton / ombre de carte par les conteneurs PatternFly */
+.pf-c-login.stacked,
+.pf-c-login.stacked .ak-login-container,
+.pf-c-page__drawer .pf-c-drawer__body,
+.pf-c-page__drawer .pf-c-drawer__content {
+ overflow: visible !important;
+}
+
+@media (max-width: 639px) {
+ .pf-c-login__main {
+ padding: 1.25rem 1.25rem 1rem !important;
+ border-radius: 1.75rem !important;
+ }
+
+ .pf-c-login__main-header.pf-c-brand img,
+ .ak-brand img,
+ .pf-c-brand img {
+ max-height: 44px;
+ max-width: min(240px, 72vw);
+ }
+
+ .pf-c-title.pf-m-3xl {
+ font-size: 1.05rem !important;
+ }
+
+ .pf-c-form-control {
+ height: 2.75rem;
+ font-size: 16px !important; /* évite le zoom iOS */
+ }
+
+ ak-flow-executor[flowslug="default-authentication-flow"],
+ ak-flow-executor[flowslug="ulti-enrollment"],
+ ak-flow-executor[flowslug="ulti-recovery"] {
+ --ulti-primary-btn-height: 2.625rem;
+ --ulti-primary-btn-min-height: 2.625rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .pf-c-background-image,
+ .pf-c-background-image::before {
+ animation: none !important;
+ }
+
+ .pf-c-login__main::before {
+ animation: none !important;
+ }
+}
diff --git a/deploy/authentik/branding/ulti-authentik.js b/deploy/authentik/branding/ulti-authentik.js
new file mode 100644
index 0000000..54a6604
--- /dev/null
+++ b/deploy/authentik/branding/ulti-authentik.js
@@ -0,0 +1,425 @@
+/**
+ * UltiSuite — enrichissements DOM des flows Authentik (shadow roots inclus).
+ * Actuellement : ulti-enrollment (suffixe e-mail, toggle mot de passe, robustesse).
+ */
+(function () {
+ "use strict";
+
+ const ENROLLMENT_SLUG = "ulti-enrollment";
+ const DEFAULT_MAIL_SUFFIX = "@ultisuite.fr";
+ const EMAIL_CHECK_DEBOUNCE_MS = 1000;
+ const MIN_EMAIL_LOCAL_LENGTH = 2;
+
+ const EYE_SVG =
+ '';
+ const EYE_OFF_SVG =
+ '';
+
+ function forEachDeep(root, visit) {
+ if (!root) return;
+ visit(root);
+ const nodes = root.querySelectorAll ? root.querySelectorAll("*") : [];
+ for (const node of nodes) {
+ if (node.shadowRoot) forEachDeep(node.shadowRoot, visit);
+ }
+ }
+
+ function flowSlug(executor) {
+ return (
+ executor.getAttribute("flowslug") ||
+ executor.getAttribute("flowSlug") ||
+ ""
+ );
+ }
+
+ function mailSuffixFromForm(form) {
+ const hidden = form.querySelector('input[name="email"]');
+ const raw = hidden?.value || hidden?.getAttribute("value") || "";
+ const at = raw.indexOf("@");
+ if (at >= 0) return raw.slice(at);
+ return DEFAULT_MAIL_SUFFIX;
+ }
+
+ function mailDomainFromForm(form) {
+ const suffix = mailSuffixFromForm(form);
+ return suffix.replace(/^@+/, "");
+ }
+
+ function normalizeSignupLocalPart(raw) {
+ return String(raw || "")
+ .replace(/@+/g, "")
+ .trim()
+ .toLowerCase();
+ }
+
+ function evaluatePasswordStrength(password) {
+ if (!password) return { level: "empty", label: "" };
+ let score = 0;
+ if (password.length >= 8) score += 1;
+ if (password.length >= 12) score += 1;
+ if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1;
+ if (/\d/.test(password)) score += 1;
+ if (/[^A-Za-z0-9]/.test(password)) score += 1;
+
+ if (score <= 1) return { level: "weak", label: "Faible" };
+ if (score === 2) return { level: "fair", label: "Moyen" };
+ if (score === 3 || score === 4) return { level: "good", label: "Bon" };
+ return { level: "strong", label: "Fort" };
+ }
+
+ function activeSegments(level) {
+ if (level === "weak") return 1;
+ if (level === "fair") return 2;
+ if (level === "good") return 3;
+ if (level === "strong") return 4;
+ return 0;
+ }
+
+ function createStrengthMeter(input) {
+ const wrap = document.createElement("div");
+ wrap.className = "ulti-password-strength";
+ wrap.hidden = true;
+ wrap.setAttribute("aria-live", "polite");
+
+ const bars = document.createElement("div");
+ bars.className = "ulti-password-strength-bars";
+ for (let i = 0; i < 4; i += 1) {
+ bars.appendChild(document.createElement("span"));
+ }
+
+ const label = document.createElement("p");
+ label.className = "ulti-password-strength-label";
+ label.innerHTML = 'Robustesse : ';
+
+ wrap.appendChild(bars);
+ wrap.appendChild(label);
+
+ const update = () => {
+ const { level, label: text } = evaluatePasswordStrength(input.value);
+ wrap.hidden = !input.value;
+ const segs = activeSegments(level);
+ bars.querySelectorAll("span").forEach((bar, index) => {
+ bar.className = index < segs ? `active ${level}` : "";
+ });
+ wrap.querySelector(".ulti-password-strength-value").textContent = text;
+ };
+
+ input.addEventListener("input", update);
+ update();
+ return wrap;
+ }
+
+ function createMatchIndicator(primary, repeat) {
+ const el = document.createElement("p");
+ el.className = "ulti-password-match";
+ el.hidden = true;
+ el.setAttribute("role", "status");
+
+ const update = () => {
+ const a = primary.value;
+ const b = repeat.value;
+ if (!b) {
+ el.hidden = true;
+ return;
+ }
+ el.hidden = false;
+ const ok = a === b;
+ el.className = ok ? "ulti-password-match ok" : "ulti-password-match bad";
+ el.textContent = ok
+ ? "Les mots de passe correspondent."
+ : "Les mots de passe ne correspondent pas.";
+ };
+
+ primary.addEventListener("input", update);
+ repeat.addEventListener("input", update);
+ update();
+ return el;
+ }
+
+ function wrapPasswordInput(input) {
+ if (input.closest(".ulti-password-wrap")) return input.closest(".ulti-password-wrap");
+
+ const wrap = document.createElement("div");
+ wrap.className = "ulti-password-wrap";
+ input.parentNode.insertBefore(wrap, input);
+ wrap.appendChild(input);
+ input.classList.add("ulti-password-input");
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "ulti-password-toggle";
+ btn.setAttribute("aria-label", "Afficher le mot de passe");
+ btn.innerHTML = EYE_SVG;
+ btn.addEventListener("click", () => {
+ const visible = input.type === "text";
+ input.type = visible ? "password" : "text";
+ btn.setAttribute(
+ "aria-label",
+ visible ? "Afficher le mot de passe" : "Masquer le mot de passe"
+ );
+ btn.innerHTML = visible ? EYE_SVG : EYE_OFF_SVG;
+ });
+ wrap.appendChild(btn);
+ return wrap;
+ }
+
+ function enhanceEmailField(form) {
+ const input = form.querySelector('input[name="username"]');
+ if (!input || input.closest(".ulti-email-input-group")) return;
+
+ form.querySelectorAll("p").forEach((p) => {
+ if (p.textContent.trim().startsWith("@")) p.hidden = true;
+ });
+
+ const group = document.createElement("div");
+ group.className = "ulti-email-input-group";
+ input.parentNode.insertBefore(group, input);
+ group.appendChild(input);
+
+ const suffix = document.createElement("span");
+ suffix.className = "ulti-email-suffix";
+ suffix.textContent = mailSuffixFromForm(form);
+ group.appendChild(suffix);
+
+ attachEmailAvailabilityChecker(form, input);
+ }
+
+ function attachEmailAvailabilityChecker(form, input) {
+ if (input.dataset.ultiAvailability === "1") return;
+ input.dataset.ultiAvailability = "1";
+
+ const hint = document.createElement("p");
+ hint.className = "ulti-email-availability";
+ hint.hidden = true;
+ hint.setAttribute("aria-live", "polite");
+
+ const host = input.closest("ak-form-element");
+ if (host) host.after(hint);
+
+ let debounceTimer = null;
+ let requestId = 0;
+ let debouncedLocal = "";
+ let status = "idle";
+
+ function submitButton() {
+ return form.querySelector(
+ '.pf-c-form__group.pf-m-action button[type="submit"]'
+ );
+ }
+
+ function setSubmitDisabled(disabled) {
+ const btn = submitButton();
+ if (!btn) return;
+ btn.disabled = disabled;
+ btn.setAttribute("aria-disabled", disabled ? "true" : "false");
+ }
+
+ function render(local) {
+ if (local.length === 0) {
+ hint.hidden = true;
+ input.removeAttribute("aria-invalid");
+ setSubmitDisabled(false);
+ return;
+ }
+
+ hint.hidden = false;
+ hint.className = "ulti-email-availability";
+
+ if (local.length < MIN_EMAIL_LOCAL_LENGTH) {
+ hint.classList.add("muted");
+ hint.textContent = "Au moins 2 caractères avant le @.";
+ input.removeAttribute("aria-invalid");
+ setSubmitDisabled(true);
+ return;
+ }
+
+ if (local !== debouncedLocal || status === "checking") {
+ hint.classList.add("checking");
+ hint.textContent = "Vérification de la disponibilité…";
+ input.removeAttribute("aria-invalid");
+ setSubmitDisabled(true);
+ return;
+ }
+
+ if (status === "available") {
+ hint.classList.add("ok");
+ hint.setAttribute("role", "status");
+ hint.textContent = "Cette adresse est disponible.";
+ input.removeAttribute("aria-invalid");
+ setSubmitDisabled(false);
+ return;
+ }
+
+ if (status === "taken") {
+ hint.classList.add("bad");
+ hint.setAttribute("role", "alert");
+ hint.textContent = "Cette adresse est déjà prise.";
+ input.setAttribute("aria-invalid", "true");
+ setSubmitDisabled(true);
+ return;
+ }
+
+ if (status === "error") {
+ hint.classList.add("muted");
+ hint.textContent =
+ "Impossible de vérifier la disponibilité pour le moment.";
+ input.removeAttribute("aria-invalid");
+ setSubmitDisabled(false);
+ return;
+ }
+
+ hint.hidden = true;
+ setSubmitDisabled(false);
+ }
+
+ async function runCheck(local) {
+ const domain = mailDomainFromForm(form);
+ const id = ++requestId;
+ status = "checking";
+ render(local);
+
+ try {
+ const params = new URLSearchParams({ local, domain });
+ const res = await fetch(
+ `/api/v1/mail/addresses/check?${params.toString()}`,
+ {
+ headers: { Accept: "application/json" },
+ credentials: "include",
+ }
+ );
+ if (id !== requestId) return;
+ if (!res.ok) throw new Error(`address check failed (${res.status})`);
+ const data = await res.json();
+ status = data.available ? "available" : "taken";
+ } catch {
+ if (id !== requestId) return;
+ status = "error";
+ }
+
+ render(normalizeSignupLocalPart(input.value));
+ }
+
+ function scheduleCheck() {
+ const local = normalizeSignupLocalPart(input.value);
+ window.clearTimeout(debounceTimer);
+
+ if (local.length < MIN_EMAIL_LOCAL_LENGTH) {
+ requestId += 1;
+ debouncedLocal = local;
+ status = "idle";
+ render(local);
+ return;
+ }
+
+ status = "checking";
+ render(local);
+
+ debounceTimer = window.setTimeout(() => {
+ debouncedLocal = local;
+ void runCheck(local);
+ }, EMAIL_CHECK_DEBOUNCE_MS);
+ }
+
+ input.addEventListener("input", scheduleCheck);
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "@") event.preventDefault();
+ });
+ input.addEventListener("paste", (event) => {
+ const text = event.clipboardData?.getData("text") ?? "";
+ if (!text.includes("@")) return;
+ event.preventDefault();
+ input.value = normalizeSignupLocalPart(text);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ scheduleCheck();
+ }
+
+ function enhanceEnrollmentForm(form) {
+ if (form.dataset.ultiEnhanced === "1") return;
+ if (!form.querySelector('input[name="username"]')) return;
+
+ form.dataset.ultiEnhanced = "1";
+ enhanceEmailField(form);
+
+ const password = form.querySelector('input[name="password"]');
+ const repeat = form.querySelector('input[name="password_repeat"]');
+ if (!password || !repeat) return;
+
+ wrapPasswordInput(password);
+ wrapPasswordInput(repeat);
+
+ const passwordHost = password.closest("ak-form-element");
+ if (passwordHost && !form.querySelector(".ulti-password-strength")) {
+ passwordHost.after(createStrengthMeter(password));
+ }
+
+ const repeatHost = repeat.closest("ak-form-element");
+ if (repeatHost && !form.querySelector(".ulti-password-match")) {
+ repeatHost.after(createMatchIndicator(password, repeat));
+ }
+ }
+
+ function scan() {
+ const executor = document.querySelector("ak-flow-executor");
+ if (!executor || flowSlug(executor) !== ENROLLMENT_SLUG) return false;
+
+ let found = false;
+ if (executor.shadowRoot) {
+ forEachDeep(executor.shadowRoot, (root) => {
+ root.querySelectorAll?.("form.pf-c-form")?.forEach((form) => {
+ found = true;
+ enhanceEnrollmentForm(form);
+ });
+ });
+ }
+ return found;
+ }
+
+ function observeExecutor(executor) {
+ if (!executor?.shadowRoot || executor.dataset.ultiObserved === "1") return;
+ executor.dataset.ultiObserved = "1";
+ new MutationObserver(() => scan()).observe(executor.shadowRoot, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ function boot() {
+ let tries = 0;
+ const tick = () => {
+ tries += 1;
+ const executor = document.querySelector("ak-flow-executor");
+ observeExecutor(executor);
+ scan();
+ const enhanced = (() => {
+ const executor = document.querySelector("ak-flow-executor");
+ if (!executor?.shadowRoot) return null;
+ let match = null;
+ forEachDeep(executor.shadowRoot, (root) => {
+ match =
+ match ||
+ root.querySelector?.('form.pf-c-form[data-ulti-enhanced="1"]') ||
+ null;
+ });
+ return match;
+ })();
+ if (!enhanced && tries < 80) {
+ window.setTimeout(tick, 150);
+ }
+ };
+
+ tick();
+
+ new MutationObserver(() => {
+ observeExecutor(document.querySelector("ak-flow-executor"));
+ scan();
+ }).observe(document.body, { childList: true, subtree: true });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", boot);
+ } else {
+ boot();
+ }
+})();
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index b431cab..41fc09f 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -111,6 +111,8 @@ services:
volumes:
- ./authentik/blueprints:/blueprints/custom:ro
- ./authentik/templates:/templates:ro
+ - ./authentik/branding/ulti-authentik.css:/web/dist/custom.css:ro
+ - ./authentik/branding/ulti-authentik.js:/web/dist/custom.js:ro
- ./authentik/branding/ultisuite-logo-light.png:/web/dist/assets/branding/ultisuite-logo-light.png:ro
- ./authentik/branding/ultisuite-logo-dark.png:/web/dist/assets/branding/ultisuite-logo-dark.png:ro
- ./authentik/branding/ultisuite-favicon.png:/web/dist/assets/branding/ultisuite-favicon.png:ro
diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template
index b9ff30e..935288b 100644
--- a/deploy/nginx/default.conf.template
+++ b/deploy/nginx/default.conf.template
@@ -271,7 +271,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
}
- location ^~ /api/v1/calendar/ {
+ location ^~ /api/v1/calendar {
resolver 127.0.0.11 valid=10s ipv6=off;
set $ultid_upstream ultid:8080;
proxy_hide_header Access-Control-Allow-Origin;
@@ -589,6 +589,39 @@ server {
proxy_set_header Connection $connection_upgrade;
}
+ # Authentik brand CSS — bind-mount on authentik-server; must not sit behind CF 4h cache.
+ location = /auth/static/dist/custom.css {
+ resolver 127.0.0.11 valid=10s ipv6=off;
+ set $authentik_upstream authentik-server:9000;
+ proxy_pass http://$authentik_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
+ proxy_hide_header Cache-Control;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Cache-Control "no-cache, must-revalidate" always;
+ add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:3004 http://127.0.0.1:3004" always;
+ }
+
+ location = /auth/static/dist/custom.js {
+ resolver 127.0.0.11 valid=10s ipv6=off;
+ set $authentik_upstream authentik-server:9000;
+ proxy_pass http://$authentik_upstream;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
+ proxy_hide_header Cache-Control;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Cache-Control "no-cache, must-revalidate" always;
+ add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:3004 http://127.0.0.1:3004" always;
+ }
+
location /auth/ {
resolver 127.0.0.11 valid=10s ipv6=off;
set $authentik_upstream authentik-server:9000;
@@ -600,11 +633,16 @@ server {
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
+ # sub_filter ne fonctionne pas sur les réponses gzip — désactiver la compression amont.
+ proxy_set_header Accept-Encoding "";
proxy_read_timeout 86400;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
# Permet l’embed du portail Authentik dans la suite (même host + dev Next :3004).
add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:3004 http://127.0.0.1:3004" always;
+ sub_filter_once off;
+ sub_filter_types text/html;
+ sub_filter '' '';
}
location /meet/ {
@@ -617,6 +655,23 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_read_timeout 86400;
+
+ # Jitsi serves root-relative assets (/css, /libs) — prefix for subpath /meet/
+ proxy_set_header Accept-Encoding "";
+ sub_filter_once off;
+ sub_filter_types text/html;
+ sub_filter '= 15 {
+ return fmt.Errorf("too many oauth redirects")
+ }
+ return nil
+ },
+ }
+
+ params := url.Values{}
+ params.Set("client_id", b.cfg.clientID)
+ params.Set("redirect_uri", b.cfg.redirectURI)
+ params.Set("response_type", "code")
+ params.Set("scope", "openid profile email offline_access")
+ params.Set("state", state)
+ params.Set("code_challenge", challenge)
+ params.Set("code_challenge_method", "S256")
+
+ authURL := b.cfg.authorizeURL + "?" + params.Encode()
+ captured, err = b.runAuthorize(ctx, client, jar, authURL, captured)
+ if err != nil {
+ return "", "", "", err
+ }
+ if captured == nil {
+ return "", "", "", fmt.Errorf("oidc authorize did not return an authorization code")
+ }
+ if captured.Query().Get("code") == "" {
+ return "", "", "", fmt.Errorf("oidc authorize missing code (session may be unauthenticated)")
+ }
+ if captured.Query().Get("state") != state {
+ return "", "", "", fmt.Errorf("oidc authorize state mismatch")
+ }
+
+ return captured.String(), verifier, state, nil
+}
+
+// runAuthorize performs the authorize request and, if Authentik routes through the
+// authorization flow (implicit consent), drives that flow via the executor API to obtain the code.
+func (b *oidcBridge) runAuthorize(ctx context.Context, client *http.Client, jar http.CookieJar, authURL string, captured *url.URL) (*url.URL, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ finalURL := resp.Request.URL
+ loc := resp.Header.Get("Location")
+ status := resp.StatusCode
+ io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
+ resp.Body.Close()
+
+ if captured != nil {
+ return captured, nil
+ }
+ if strings.HasPrefix(loc, b.cfg.redirectURI) {
+ return url.Parse(loc)
+ }
+
+ // Landed on the authorization flow UI: drive it via the flow executor (implicit consent).
+ if slug := authorizationFlowSlug(finalURL); slug != "" {
+ slog.Info("oidc bridge driving authorization flow", "slug", slug)
+ code, err := b.driveAuthorizationFlow(ctx, client, slug, finalURL)
+ if err != nil {
+ return nil, err
+ }
+ if code != nil {
+ return code, nil
+ }
+ }
+
+ slog.Warn("oidc bridge no code", "status", status, "final", redactQuery(finalURL), "location", loc)
+ if loc != "" {
+ return nil, fmt.Errorf("oidc authorize unexpected redirect to %s (status %d)", loc, status)
+ }
+ return nil, fmt.Errorf("oidc authorize did not return an authorization code (status %d)", status)
+}
+
+// driveAuthorizationFlow runs the provider authorization flow executor and follows its
+// terminal redirect, returning the captured callback URL with the authorization code.
+func (b *oidcBridge) driveAuthorizationFlow(ctx context.Context, client *http.Client, slug string, flowURL *url.URL) (*url.URL, error) {
+ next := flowURL.Query().Get("next")
+ execURL := strings.TrimSuffix(b.cfg.authentikBase, "/") + "/api/v3/flows/executor/" + slug + "/"
+ if next != "" {
+ execURL += "?query=" + url.QueryEscape(strings.TrimPrefix(next, "/auth"))
+ }
+
+ for i := 0; i < 6; i++ {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, execURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<18))
+ resp.Body.Close()
+
+ var challenge map[string]any
+ _ = json.Unmarshal(body, &challenge)
+ comp, _ := challenge["component"].(string)
+ slog.Info("oidc bridge authz flow stage", "component", comp)
+
+ switch comp {
+ case "xak-flow-redirect":
+ to, _ := challenge["to"].(string)
+ if strings.HasPrefix(to, b.cfg.redirectURI) {
+ return url.Parse(to)
+ }
+ if u := b.followToCallback(ctx, client, to); u != nil {
+ return u, nil
+ }
+ return nil, fmt.Errorf("authorization flow redirected to %s (no code)", to)
+ case "ak-stage-consent":
+ token, _ := challenge["token"].(string)
+ postReq, err := postAuthorizeConsent(ctx, execURL, token, client)
+ if err != nil {
+ return nil, err
+ }
+ if postReq != nil {
+ return postReq, nil
+ }
+ default:
+ return nil, fmt.Errorf("authorization flow stuck at stage %q", comp)
+ }
+ }
+ return nil, fmt.Errorf("authorization flow did not complete")
+}
+
+func postAuthorizeConsent(ctx context.Context, execURL, token string, client *http.Client) (*url.URL, error) {
+ payload := map[string]any{"component": "ak-stage-consent"}
+ if token != "" {
+ payload["token"] = token
+ }
+ raw, _ := json.Marshal(payload)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, execURL, strings.NewReader(string(raw)))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Referer", execURL)
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<18))
+ resp.Body.Close()
+ var challenge map[string]any
+ _ = json.Unmarshal(body, &challenge)
+ if to, _ := challenge["to"].(string); to != "" {
+ return url.Parse(to)
+ }
+ return nil, nil
+}
+
+func (b *oidcBridge) followToCallback(ctx context.Context, client *http.Client, to string) *url.URL {
+ if to == "" {
+ return nil
+ }
+ target := to
+ if strings.HasPrefix(to, "/") {
+ target = strings.TrimSuffix(b.cfg.authentikBase, "/") + to
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
+ if err != nil {
+ return nil
+ }
+ var captured *url.URL
+ client.CheckRedirect = func(r *http.Request, via []*http.Request) error {
+ if strings.HasPrefix(r.URL.String(), b.cfg.redirectURI) {
+ captured = r.URL
+ return http.ErrUseLastResponse
+ }
+ if len(via) >= 10 {
+ return fmt.Errorf("too many redirects")
+ }
+ return nil
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil
+ }
+ loc := resp.Header.Get("Location")
+ io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
+ resp.Body.Close()
+ if captured != nil {
+ return captured
+ }
+ if strings.HasPrefix(loc, b.cfg.redirectURI) {
+ u, _ := url.Parse(loc)
+ return u
+ }
+ return nil
+}
+
+func authorizationFlowSlug(u *url.URL) string {
+ if u == nil {
+ return ""
+ }
+ // Matches /auth/if/flow// and /if/flow//
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
+ for i := 0; i+1 < len(parts); i++ {
+ if parts[i] == "flow" && i >= 1 && parts[i-1] == "if" {
+ slug := parts[i+1]
+ if strings.Contains(slug, "authentication") {
+ return "" // login flow → not authenticated, don't loop
+ }
+ return slug
+ }
+ }
+ return ""
+}
+
+func redactQuery(u *url.URL) string {
+ if u == nil {
+ return ""
+ }
+ c := *u
+ c.RawQuery = ""
+ return c.String()
+}
diff --git a/internal/api/auth/pkce.go b/internal/api/auth/pkce.go
new file mode 100644
index 0000000..7c21ef0
--- /dev/null
+++ b/internal/api/auth/pkce.go
@@ -0,0 +1,31 @@
+package authapi
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+)
+
+func newPKCEPair() (verifier, challenge string, err error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", "", err
+ }
+ verifier = base64.RawURLEncoding.EncodeToString(b)
+ sum := sha256.Sum256([]byte(verifier))
+ challenge = base64.RawURLEncoding.EncodeToString(sum[:])
+ return verifier, challenge, nil
+}
+
+func pkceChallengeFromVerifier(verifier string) string {
+ sum := sha256.Sum256([]byte(verifier))
+ return base64.RawURLEncoding.EncodeToString(sum[:])
+}
+
+func randomOAuthState() (string, error) {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
diff --git a/internal/api/middleware/forwarded.go b/internal/api/middleware/forwarded.go
new file mode 100644
index 0000000..2e5a19d
--- /dev/null
+++ b/internal/api/middleware/forwarded.go
@@ -0,0 +1,28 @@
+package middleware
+
+import (
+ "net/http"
+ "strings"
+)
+
+// ForwardedHeaders sets r.URL.Scheme and r.URL.Host from reverse-proxy headers so
+// chi RedirectSlashes and other absolute redirects use https behind Cloudflare/nginx.
+func ForwardedHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" {
+ if i := strings.IndexByte(proto, ','); i >= 0 {
+ proto = strings.TrimSpace(proto[:i])
+ }
+ r.URL.Scheme = proto
+ }
+ if host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); host != "" {
+ if i := strings.IndexByte(host, ','); i >= 0 {
+ host = strings.TrimSpace(host[:i])
+ }
+ r.URL.Host = host
+ } else if r.Host != "" {
+ r.URL.Host = r.Host
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/api/middleware/forwarded_test.go b/internal/api/middleware/forwarded_test.go
new file mode 100644
index 0000000..6118971
--- /dev/null
+++ b/internal/api/middleware/forwarded_test.go
@@ -0,0 +1,28 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestForwardedHeaders(t *testing.T) {
+ var gotScheme, gotHost string
+ handler := ForwardedHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotScheme = r.URL.Scheme
+ gotHost = r.URL.Host
+ }))
+
+ req := httptest.NewRequest(http.MethodGet, "http://ultid:8080/api/v1/calendar", nil)
+ req.Header.Set("X-Forwarded-Proto", "https")
+ req.Header.Set("X-Forwarded-Host", "dev.ultispace.fr")
+ req.Host = "dev.ultispace.fr"
+ handler.ServeHTTP(httptest.NewRecorder(), req)
+
+ if gotScheme != "https" {
+ t.Fatalf("scheme = %q, want https", gotScheme)
+ }
+ if gotHost != "dev.ultispace.fr" {
+ t.Fatalf("host = %q, want dev.ultispace.fr", gotHost)
+ }
+}
diff --git a/internal/authentik/flow_executor.go b/internal/authentik/flow_executor.go
index 328aad4..6289629 100644
--- a/internal/authentik/flow_executor.go
+++ b/internal/authentik/flow_executor.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
@@ -50,26 +51,44 @@ func NewFlowExecutor(baseURL, slug string) (*FlowExecutor, error) {
func (fe *FlowExecutor) executorURL(query string) string {
u := fmt.Sprintf("%s/api/v3/flows/executor/%s/", fe.baseURL, fe.slug)
if query != "" {
- return u + "?" + query
+ // Authentik's flow executor reads the original frontend querystring from a `query`
+ // parameter (e.g. ?query=next%3D...). Passing the params directly is ignored.
+ return u + "?query=" + url.QueryEscape(query)
}
return u
}
// GetChallenge starts or resumes a flow and returns the pending challenge.
func (fe *FlowExecutor) GetChallenge(ctx context.Context, query string) (FlowChallenge, error) {
- if err := fe.warmSession(ctx); err != nil {
- return nil, err
- }
+ // Establish the flow plan via the executor GET with the query string so the OAuth `next`
+ // continuation is captured in the plan. Calling execute/ first would create a plan without
+ // `next`, and the executor GET would then reuse that plan and ignore our continuation.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fe.executorURL(query), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
- return fe.doChallenge(req)
+ challenge, err := fe.doChallenge(req)
+ if err == nil {
+ return challenge, nil
+ }
+ // Fallback: some Authentik configs require a warmed session cookie before the executor GET.
+ if warmErr := fe.warmSession(ctx, query); warmErr != nil {
+ return nil, err
+ }
+ req2, err2 := http.NewRequestWithContext(ctx, http.MethodGet, fe.executorURL(query), nil)
+ if err2 != nil {
+ return nil, err2
+ }
+ req2.Header.Set("Accept", "application/json")
+ return fe.doChallenge(req2)
}
-func (fe *FlowExecutor) warmSession(ctx context.Context) error {
+func (fe *FlowExecutor) warmSession(ctx context.Context, query string) error {
u := fmt.Sprintf("%s/api/v3/flows/instances/%s/execute/", fe.baseURL, fe.slug)
+ if query != "" {
+ u += "?query=" + url.QueryEscape(query)
+ }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
@@ -106,6 +125,236 @@ func (fe *FlowExecutor) PostResponse(ctx context.Context, query string, payload
return fe.doChallenge(req)
}
+// FollowFlowRedirect loads the flow completion URL so Authentik can bind an authenticated session.
+func (fe *FlowExecutor) FollowFlowRedirect(ctx context.Context, target string) error {
+ target = strings.TrimSpace(target)
+ if target == "" {
+ return nil
+ }
+ resolved, err := fe.resolveRedirectURL(target)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolved, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := fe.client.Do(req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode >= 500 {
+ return fmt.Errorf("follow flow redirect: %d", resp.StatusCode)
+ }
+ return nil
+}
+
+// CaptureOAuthCallback follows the post-authentication authorize redirect (`to`) within the
+// executor's live, authenticated jar and returns the external callback URL carrying the
+// authorization code. This mirrors what a browser does after login and is the supported way to
+// bridge an embedded (API-driven) Authentik session to an OIDC authorization code.
+func (fe *FlowExecutor) CaptureOAuthCallback(ctx context.Context, to string) (string, error) {
+ toURL, err := url.Parse(to)
+ if err != nil {
+ return "", fmt.Errorf("parse authorize redirect: %w", err)
+ }
+ redirectURI := toURL.Query().Get("redirect_uri")
+ if redirectURI == "" {
+ return "", fmt.Errorf("authorize redirect missing redirect_uri")
+ }
+ resolved, err := fe.resolveRedirectURL(to)
+ if err != nil {
+ return "", err
+ }
+
+ var captured *url.URL
+ client := &http.Client{
+ Jar: fe.client.Jar,
+ Timeout: fe.client.Timeout,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if strings.HasPrefix(req.URL.String(), redirectURI) {
+ captured = req.URL
+ return http.ErrUseLastResponse
+ }
+ if len(via) >= 15 {
+ return fmt.Errorf("too many authorize redirects")
+ }
+ return nil
+ },
+ }
+
+ current := resolved
+ for i := 0; i < 6; i++ {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, current, nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Accept", "text/html,application/xhtml+xml")
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ loc := resp.Header.Get("Location")
+ finalURL := resp.Request.URL
+ io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
+ resp.Body.Close()
+
+ if captured != nil {
+ return captured.String(), nil
+ }
+ if strings.HasPrefix(loc, redirectURI) {
+ return loc, nil
+ }
+
+ // Authorize routed through a flow (e.g. implicit-consent authorization flow). Drive it
+ // via the executor API to obtain the terminal redirect with the code.
+ slug := flowSlugFromURL(finalURL)
+ if slug == "" {
+ return "", fmt.Errorf("authorize did not yield a callback (landed on %s)", redactURL(finalURL))
+ }
+ callback, next, err := fe.driveFlowToRedirect(ctx, client, slug, finalURL, redirectURI)
+ if err != nil {
+ return "", err
+ }
+ if callback != "" {
+ return callback, nil
+ }
+ if next == "" {
+ return "", fmt.Errorf("authorization flow %q did not complete", slug)
+ }
+ current = next
+ }
+ return "", fmt.Errorf("authorize redirect chain did not resolve to a callback")
+}
+
+// driveFlowToRedirect runs a flow executor (used for the implicit-consent authorization flow)
+// until it produces a terminal redirect. Returns either a captured callback URL, or a next URL
+// to continue following.
+func (fe *FlowExecutor) driveFlowToRedirect(ctx context.Context, client *http.Client, slug string, flowURL *url.URL, redirectURI string) (callback string, next string, err error) {
+ q := flowURL.Query().Get("query")
+ if q == "" {
+ q = flowURL.RawQuery
+ }
+ execURL := fmt.Sprintf("%s/api/v3/flows/executor/%s/", fe.baseURL, slug)
+ if q != "" {
+ execURL += "?query=" + url.QueryEscape(q)
+ }
+
+ for i := 0; i < 6; i++ {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, execURL, nil)
+ if err != nil {
+ return "", "", err
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", "", err
+ }
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<18))
+ resp.Body.Close()
+ var ch FlowChallenge
+ _ = json.Unmarshal(body, &ch)
+ comp := FlowComponent(ch)
+ slog.Info("capture authz flow stage", "slug", slug, "component", comp)
+
+ switch comp {
+ case "xak-flow-redirect":
+ toStr, _ := ch["to"].(string)
+ if strings.HasPrefix(toStr, redirectURI) {
+ return toStr, "", nil
+ }
+ resolved, rErr := fe.resolveRedirectURL(toStr)
+ if rErr != nil {
+ return "", "", rErr
+ }
+ return "", resolved, nil
+ case "ak-stage-consent":
+ payload := map[string]any{"component": "ak-stage-consent"}
+ pr, pErr := fe.postFlowStage(ctx, client, execURL, payload)
+ if pErr != nil {
+ return "", "", pErr
+ }
+ if to, _ := pr["to"].(string); to != "" {
+ if strings.HasPrefix(to, redirectURI) {
+ return to, "", nil
+ }
+ resolved, _ := fe.resolveRedirectURL(to)
+ return "", resolved, nil
+ }
+ default:
+ return "", "", fmt.Errorf("authorization flow %q stuck at %q", slug, comp)
+ }
+ }
+ return "", "", fmt.Errorf("authorization flow %q did not complete", slug)
+}
+
+func (fe *FlowExecutor) postFlowStage(ctx context.Context, client *http.Client, execURL string, payload map[string]any) (FlowChallenge, error) {
+ raw, _ := json.Marshal(payload)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, execURL, bytes.NewReader(raw))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Referer", execURL)
+ if csrf := fe.csrfToken(); csrf != "" {
+ req.Header.Set("X-authentik-CSRF", csrf)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<18))
+ var ch FlowChallenge
+ _ = json.Unmarshal(body, &ch)
+ return ch, nil
+}
+
+func flowSlugFromURL(u *url.URL) string {
+ if u == nil {
+ return ""
+ }
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
+ for i := 0; i+1 < len(parts); i++ {
+ if parts[i] == "flow" && i >= 1 && parts[i-1] == "if" {
+ slug := parts[i+1]
+ if strings.Contains(slug, "authentication") {
+ return ""
+ }
+ return slug
+ }
+ }
+ return ""
+}
+
+func redactURL(u *url.URL) string {
+ if u == nil {
+ return ""
+ }
+ c := *u
+ c.RawQuery = ""
+ return c.String()
+}
+
+func (fe *FlowExecutor) resolveRedirectURL(target string) (string, error) {
+ if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
+ return target, nil
+ }
+ base, err := url.Parse(fe.baseURL)
+ if err != nil {
+ return "", err
+ }
+ rel, err := url.Parse(target)
+ if err != nil {
+ return "", err
+ }
+ return base.ResolveReference(rel).String(), nil
+}
+
func (fe *FlowExecutor) cookieURL() *url.URL {
u, err := url.Parse(fe.baseURL)
if err != nil || u.Host == "" {
diff --git a/internal/authentik/flow_executor_redirect_test.go b/internal/authentik/flow_executor_redirect_test.go
new file mode 100644
index 0000000..24e0798
--- /dev/null
+++ b/internal/authentik/flow_executor_redirect_test.go
@@ -0,0 +1,38 @@
+package authentik
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestFollowFlowRedirectUpdatesJar(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "authentik_session",
+ Value: "authenticated-session",
+ Path: "/auth/",
+ })
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(srv.Close)
+
+ fe, err := NewFlowExecutor(srv.URL+"/auth", "default-authentication-flow")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := fe.FollowFlowRedirect(context.Background(), "/auth/done"); err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, c := range fe.client.Jar.Cookies(fe.cookieURL()) {
+ if c.Name == "authentik_session" && c.Value == "authenticated-session" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("expected session cookie after follow redirect")
+ }
+}
diff --git a/internal/authentik/flow_session_store.go b/internal/authentik/flow_session_store.go
index a443a64..ca35f58 100644
--- a/internal/authentik/flow_session_store.go
+++ b/internal/authentik/flow_session_store.go
@@ -6,6 +6,9 @@ import (
"encoding/hex"
"encoding/json"
"errors"
+ "fmt"
+ "log/slog"
+ "strings"
"sync"
"time"
@@ -18,17 +21,19 @@ const (
)
type storedFlowSession struct {
- Slug string `json:"slug"`
- Cookies []SerializedCookie `json:"cookies"`
- CreatedAt time.Time `json:"createdAt"`
- Completed bool `json:"completed,omitempty"`
+ Slug string `json:"slug"`
+ Cookies []SerializedCookie `json:"cookies"`
+ CreatedAt time.Time `json:"createdAt"`
+ Completed bool `json:"completed,omitempty"`
+ OAuthCallback string `json:"oauthCallback,omitempty"`
}
type flowSessionEntry struct {
- slug string
- cookies []SerializedCookie
- createdAt time.Time
- completed bool
+ slug string
+ cookies []SerializedCookie
+ createdAt time.Time
+ completed bool
+ oauthCallback string
}
// FlowSessionStore keeps Authentik flow executor sessions (memory + optional KeyDB).
@@ -96,35 +101,132 @@ func (s *FlowSessionStore) Respond(ctx context.Context, sessionID, slug, query s
return nil, err
}
entry.cookies = executor.ExportCookies()
- done, denied := FlowDone(challenge)
- if done && !denied {
+ challenge, err = s.advanceHeadlessStages(ctx, executor, query, challenge)
+ if err != nil {
+ return nil, err
+ }
+ entry.cookies = executor.ExportCookies()
+
+ if SessionAuthenticated(entry.cookies) {
entry.completed = true
+ // If the flow's terminal redirect is an OIDC authorize continuation, follow it within the
+ // live authenticated jar to capture the authorization code (the only reliable way to bridge
+ // an API-driven session to OIDC; replaying the session cookie elsewhere is rejected).
+ if to, ok := challenge["to"].(string); ok && isOAuthAuthorizeRedirect(to) {
+ callback, capErr := executor.CaptureOAuthCallback(ctx, to)
+ if capErr != nil {
+ slog.Warn("capture oauth callback failed", "err", capErr.Error())
+ } else {
+ entry.oauthCallback = callback
+ slog.Info("captured oauth callback", "ok", callback != "")
+ }
+ }
if err := s.save(ctx, sessionID, entry); err != nil {
return nil, err
}
+ if FlowComponent(challenge) != "xak-flow-redirect" {
+ challenge = FlowChallenge{"component": "xak-flow-redirect"}
+ }
return challenge, nil
}
+
+ done, denied := FlowDone(challenge)
+ if done && !denied {
+ return nil, fmt.Errorf("authentik flow finished without authenticated session")
+ }
if err := s.save(ctx, sessionID, entry); err != nil {
return nil, err
}
return challenge, nil
}
+func (s *FlowSessionStore) advanceHeadlessStages(ctx context.Context, executor *FlowExecutor, query string, challenge FlowChallenge) (FlowChallenge, error) {
+ const maxSteps = 8
+ for step := 0; step < maxSteps; step++ {
+ if SessionAuthenticated(executor.ExportCookies()) {
+ return challenge, nil
+ }
+ if _, denied := FlowDone(challenge); denied {
+ return challenge, nil
+ }
+
+ switch FlowComponent(challenge) {
+ case "ak-stage-user-login":
+ var err error
+ challenge, err = executor.PostResponse(ctx, query, map[string]any{"component": "ak-stage-user-login"})
+ if err != nil {
+ return nil, err
+ }
+ case "xak-flow-redirect":
+ // Resume pending stages before following terminal redirects (/auth/ resets session).
+ next, err := executor.GetChallenge(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ if comp := FlowComponent(next); comp != "" && comp != "xak-flow-redirect" {
+ challenge = next
+ continue
+ }
+ if to, ok := challenge["to"].(string); ok && isTerminalFlowRedirect(to) {
+ loginChallenge, loginErr := executor.PostResponse(ctx, query, map[string]any{"component": "ak-stage-user-login"})
+ if loginErr == nil {
+ challenge = loginChallenge
+ continue
+ }
+ return challenge, nil
+ }
+ if to, ok := challenge["to"].(string); ok && strings.TrimSpace(to) != "" {
+ if err := executor.FollowFlowRedirect(ctx, to); err != nil {
+ return nil, err
+ }
+ }
+ next, err = executor.GetChallenge(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ challenge = next
+ default:
+ return challenge, nil
+ }
+ }
+ return challenge, nil
+}
+
+func isOAuthAuthorizeRedirect(to string) bool {
+ return strings.Contains(to, "/application/o/authorize")
+}
+
+func isTerminalFlowRedirect(to string) bool {
+ to = strings.TrimSpace(to)
+ if to == "" || to == "/auth/" || to == "/auth" {
+ return true
+ }
+ return strings.HasSuffix(strings.TrimSuffix(to, "/"), "/auth")
+}
+
// CompleteSession returns cookies from a completed flow and removes the session.
func (s *FlowSessionStore) CompleteSession(ctx context.Context, sessionID, slug string) ([]SerializedCookie, error) {
+ cookies, _, err := s.CompleteSessionOAuth(ctx, sessionID, slug)
+ return cookies, err
+}
+
+// CompleteSessionOAuth returns the completed flow's cookies plus any captured OIDC callback URL
+// (carrying the authorization code) and removes the session.
+func (s *FlowSessionStore) CompleteSessionOAuth(ctx context.Context, sessionID, slug string) ([]SerializedCookie, string, error) {
entry, err := s.load(ctx, sessionID)
if err != nil {
- return nil, err
+ return nil, "", err
}
if entry.slug != slug {
- return nil, ErrFlowSessionSlugMismatch
+ return nil, "", ErrFlowSessionSlugMismatch
}
if !entry.completed {
- return nil, ErrFlowSessionNotCompleted
+ return nil, "", ErrFlowSessionNotCompleted
}
cookies := entry.cookies
+ callback := entry.oauthCallback
s.delete(ctx, sessionID)
- return cookies, nil
+ return cookies, callback, nil
}
// SessionCookies returns current Authentik cookies for an active session.
@@ -148,10 +250,11 @@ func (s *FlowSessionStore) save(ctx context.Context, sessionID string, entry *fl
return nil
}
stored := storedFlowSession{
- Slug: entry.slug,
- Cookies: entry.cookies,
- CreatedAt: entry.createdAt,
- Completed: entry.completed,
+ Slug: entry.slug,
+ Cookies: entry.cookies,
+ CreatedAt: entry.createdAt,
+ Completed: entry.completed,
+ OAuthCallback: entry.oauthCallback,
}
raw, err := json.Marshal(stored)
if err != nil {
@@ -178,10 +281,11 @@ func (s *FlowSessionStore) load(ctx context.Context, sessionID string) (*flowSes
return nil, ErrFlowSessionNotFound
}
entry := &flowSessionEntry{
- slug: stored.Slug,
- cookies: stored.Cookies,
- createdAt: stored.CreatedAt,
- completed: stored.Completed,
+ slug: stored.Slug,
+ cookies: stored.Cookies,
+ createdAt: stored.CreatedAt,
+ completed: stored.Completed,
+ oauthCallback: stored.OAuthCallback,
}
s.mu.Lock()
s.items[sessionID] = entry
diff --git a/internal/authentik/flow_session_store_test.go b/internal/authentik/flow_session_store_test.go
new file mode 100644
index 0000000..cd6cb8d
--- /dev/null
+++ b/internal/authentik/flow_session_store_test.go
@@ -0,0 +1,21 @@
+package authentik
+
+import "testing"
+
+func TestIsTerminalFlowRedirect(t *testing.T) {
+ t.Parallel()
+ for _, tc := range []struct {
+ to string
+ want bool
+ }{
+ {"/auth/", true},
+ {"/auth", true},
+ {"http://authentik-server:9000/auth/", true},
+ {"/auth/if/user/", false},
+ {"/auth/if/flow/default-authentication-flow/", false},
+ } {
+ if got := isTerminalFlowRedirect(tc.to); got != tc.want {
+ t.Fatalf("isTerminalFlowRedirect(%q) = %v, want %v", tc.to, got, tc.want)
+ }
+ }
+}
diff --git a/internal/authentik/session_cookie.go b/internal/authentik/session_cookie.go
new file mode 100644
index 0000000..7a4b237
--- /dev/null
+++ b/internal/authentik/session_cookie.go
@@ -0,0 +1,37 @@
+package authentik
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+)
+
+type sessionClaims struct {
+ Authenticated bool `json:"authenticated"`
+ Sub string `json:"sub"`
+}
+
+// SessionAuthenticated reports whether exported cookies contain a logged-in Authentik session.
+func SessionAuthenticated(stored []SerializedCookie) bool {
+ for _, sc := range stored {
+ if sc.Name != "authentik_session" || strings.TrimSpace(sc.Value) == "" {
+ continue
+ }
+ parts := strings.Split(sc.Value, ".")
+ if len(parts) < 2 {
+ continue
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ continue
+ }
+ var claims sessionClaims
+ if err := json.Unmarshal(raw, &claims); err != nil {
+ continue
+ }
+ if claims.Authenticated && claims.Sub != "" && claims.Sub != "anonymous" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/authentik/session_cookie_test.go b/internal/authentik/session_cookie_test.go
new file mode 100644
index 0000000..3d59f40
--- /dev/null
+++ b/internal/authentik/session_cookie_test.go
@@ -0,0 +1,15 @@
+package authentik
+
+import "testing"
+
+func TestSessionAuthenticated(t *testing.T) {
+ t.Parallel()
+ anonymous := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiJhYmMiLCJpc3MiOiJhdXRoZW50aWsiLCJzdWIiOiJhbm9ueW1vdXMiLCJhdXRoZW50aWNhdGVkIjpmYWxzZX0.sig"
+ auth := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiJhYmMiLCJpc3MiOiJhdXRoZW50aWsiLCJzdWIiOiJ1c2VyLTEyMyIsImF1dGhlbnRpY2F0ZWQiOnRydWV9.sig"
+ if SessionAuthenticated([]SerializedCookie{{Name: "authentik_session", Value: anonymous}}) {
+ t.Fatal("expected anonymous session to be rejected")
+ }
+ if !SessionAuthenticated([]SerializedCookie{{Name: "authentik_session", Value: auth}}) {
+ t.Fatal("expected authenticated session")
+ }
+}
diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go
index 6d47112..82b2a1c 100644
--- a/internal/server/bootstrap.go
+++ b/internal/server/bootstrap.go
@@ -344,6 +344,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
r := chi.NewRouter()
r.Use(httpcors.Middleware(cfg))
+ r.Use(middleware.ForwardedHeaders)
r.Use(middleware.TraceID)
r.Use(observability.HTTPMetrics)
r.Use(middleware.Logging)
@@ -368,7 +369,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
r.Get("/api/v1/mail/addresses/check", mailHandler.CheckAddressAvailability)
r.Get("/api/v1/migration/invite", migrationHandler.GetInvite)
r.Post("/internal/provision/user", provisionHandler.ProvisionUser)
- r.Mount("/api/v1/auth", authapi.NewHandler(cfg.AuthentikAPIURL, cfg.MailAppURL, rdb).Routes())
+ r.Mount("/api/v1/auth", authapi.NewHandler(cfg.AuthentikAPIURL, cfg.MailAppURL, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret, rdb).Routes())
var driveHandler *drive.Handler
var driveSvc *drive.Service