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