feat(authentik): enhance OIDC flow with new logout redirect and branding support
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run

- Added a new blueprint for OIDC logout that invalidates the Authentik session and redirects to a specified landing page.
- Introduced custom CSS and JS files for branding, improving the visual integration of Authentik flows.
- Updated Nginx configuration to serve the new branding assets and handle specific routes for signup and password recovery.
- Enhanced the flow completion logic to support OIDC bridge functionality, including session management and redirect handling.
- Implemented unit tests for the new OIDC bridge and flow context functionalities to ensure reliability.
This commit is contained in:
R3D347HR4Y 2026-06-21 00:12:53 +02:00
parent f7ef89fa82
commit 525edb188a
23 changed files with 2849 additions and 86 deletions

View File

@ -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

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
const EYE_OFF_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/></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 : <strong class="ulti-password-strength-value"></strong>';
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();
}
})();

View File

@ -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

View File

@ -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 lembed 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 '<link rel="stylesheet" type="text/css" href="/auth/static/dist/custom.css" data-inject>' '<link rel="stylesheet" type="text/css" href="/auth/static/dist/custom.css" data-inject><script src="/auth/static/dist/custom.js" defer></script>';
}
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 '<base href="/"' '<base href="/meet/"';
sub_filter 'src="libs/' 'src="/meet/libs/';
sub_filter 'href="css/' 'href="/meet/css/';
sub_filter 'href="images/' 'href="/meet/images/';
sub_filter 'href="libs/' 'href="/meet/libs/';
sub_filter "'/libs/" "'/meet/libs/";
sub_filter '"/libs/' '"/meet/libs/';
sub_filter "'/css/" "'/meet/css/";
sub_filter '"/css/' '"/meet/css/';
}
# Public Nextcloud share links → UltiDrive viewer
@ -1043,6 +1098,45 @@ server {
proxy_set_header Connection $connection_upgrade;
}
location ^~ /signup {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
proxy_pass http://$mail_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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location ^~ /forgot-password {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
proxy_pass http://$mail_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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location ^~ /reset-password {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
proxy_pass http://$mail_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_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location ^~ /chat {
resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};

View File

@ -20,17 +20,12 @@ type flowCompleteResponse struct {
}
func (h *Handler) CompleteFlow(w http.ResponseWriter, r *http.Request) {
sessionID := readFlowSessionCookie(r)
if sessionID == "" {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "flow_session_missing", "flow session cookie required", nil)
return
}
returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo"))
if returnTo == "" && r.Body != nil {
var req flowCompleteRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
returnTo = strings.TrimSpace(req.ReturnTo)
}
returnTo := strings.TrimSpace(req.ReturnTo)
if returnTo == "" {
returnTo = "/mail/inbox"
}
@ -39,8 +34,14 @@ func (h *Handler) CompleteFlow(w http.ResponseWriter, r *http.Request) {
return
}
sessionID := readFlowSessionCookie(r)
if sessionID == "" {
apiresponse.WriteError(w, r, http.StatusUnauthorized, "flow_session_missing", "flow session cookie required", nil)
return
}
slug := FlowAuthentication
cookies, err := h.flows.CompleteSession(r.Context(), sessionID, slug)
cookies, capturedCallback, err := h.flows.CompleteSessionOAuth(r.Context(), sessionID, slug)
if err != nil {
if errors.Is(err, authentik.ErrFlowSessionNotFound) {
clearFlowSessionCookie(w)
@ -60,28 +61,111 @@ func (h *Handler) CompleteFlow(w http.ResponseWriter, r *http.Request) {
}
clearFlowSessionCookie(w)
setBrowserAuthentikCookies(w, cookies)
loginURL := buildLoginRedirectURL(h.appURL, returnTo)
apiresponse.WriteJSON(w, http.StatusOK, flowCompleteResponse{RedirectURL: loginURL})
if !authentik.SessionAuthenticated(cookies) {
msg := "authentik session not authenticated"
if r.Method == http.MethodGet {
base := suiteAuthAppOrigin(h.appURL)
http.Redirect(w, r, base+"/login?error="+url.QueryEscape("oidc_bridge_failed:"+msg), http.StatusFound)
return
}
apiresponse.WriteError(w, r, http.StatusBadGateway, "oidc_bridge_failed", msg, nil)
return
}
// The embedded flow already followed the OIDC authorize continuation within its authenticated
// session and captured the callback URL (carrying the authorization code). The browser holds the
// matching PKCE verifier + state cookies (set by the flow context endpoint), so we just redirect
// it to the Next.js callback which performs the token exchange.
var redirectURL string
if capturedCallback != "" {
redirectURL = capturedCallback
} else {
// Fallback: authorize server-side using the authenticated Authentik session.
callbackURL, pkceVerifier, state, bErr := h.oidc.callbackURL(r.Context(), cookies)
if bErr != nil {
if r.Method == http.MethodGet {
base := suiteAuthAppOrigin(h.appURL)
http.Redirect(w, r, base+"/login?error="+url.QueryEscape("oidc_bridge_failed:"+bErr.Error()), http.StatusFound)
return
}
apiresponse.WriteError(w, r, http.StatusBadGateway, "oidc_bridge_failed", bErr.Error(), nil)
return
}
setOAuthBridgeCookies(w, r, pkceVerifier, state, returnTo)
redirectURL = callbackURL
}
if r.Method == http.MethodGet {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
apiresponse.WriteJSON(w, http.StatusOK, flowCompleteResponse{RedirectURL: redirectURL})
}
func setOAuthBridgeCookies(w http.ResponseWriter, r *http.Request, verifier, state, returnTo string) {
secure := requestIsHTTPS(r)
maxAge := 600
set := func(name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
}
set("ulti_pkce_verifier", verifier)
set("ulti_oauth_state", state)
set("ulti_auth_return", returnTo)
}
// suiteAuthAppOrigin is the suite root (auth routes live at /api/auth/*, not under /mail).
func suiteAuthAppOrigin(appURL string) string {
base := strings.TrimRight(strings.TrimSpace(appURL), "/")
if strings.HasSuffix(base, "/mail") {
base = strings.TrimSuffix(base, "/mail")
}
if base == "" {
return "http://localhost:3004"
}
return base
}
func buildLoginRedirectURL(appURL, returnTo string) string {
base := strings.TrimRight(strings.TrimSpace(appURL), "/")
if base == "" {
base = "http://localhost:3004"
}
base := suiteAuthAppOrigin(appURL)
params := url.Values{}
params.Set("returnTo", returnTo)
params.Set("bridge", "1")
return base + "/api/auth/login?" + params.Encode()
}
func setBrowserAuthentikCookies(w http.ResponseWriter, stored []authentik.SerializedCookie) {
func setBrowserAuthentikCookies(w http.ResponseWriter, r *http.Request, stored []authentik.SerializedCookie) {
secure := requestIsHTTPS(r)
for _, c := range authentik.BrowserAuthentikCookies(stored) {
if secure {
c.Secure = true
}
http.SetCookie(w, c)
}
}
func forwardFlowCookies(w http.ResponseWriter, stored []authentik.SerializedCookie) {
setBrowserAuthentikCookies(w, stored)
func requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return true
}
if strings.EqualFold(r.Header.Get("X-Forwarded-Ssl"), "on") {
return true
}
return false
}
func forwardFlowCookies(w http.ResponseWriter, r *http.Request, stored []authentik.SerializedCookie) {
setBrowserAuthentikCookies(w, r, stored)
}

View File

@ -33,20 +33,26 @@ type Handler struct {
flows *authentik.FlowSessionStore
limiter *FlowRateLimiter
appURL string
oidcClientID string
oidc *oidcBridge
}
func NewHandler(baseURL, appURL string, rdb *redis.Client) *Handler {
func NewHandler(baseURL, appURL, oidcIssuer, oidcClientID, oidcClientSecret string, rdb *redis.Client) *Handler {
return &Handler{
flows: authentik.NewFlowSessionStore(baseURL, rdb),
limiter: NewFlowRateLimiter(rdb),
appURL: appURL,
oidcClientID: strings.TrimSpace(oidcClientID),
oidc: newOIDCBridge(buildOIDCBridgeConfig(oidcIssuer, oidcClientID, oidcClientSecret, appURL, baseURL)),
}
}
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/flows/authentication/context", h.PrepareOAuthFlowContext)
r.Post("/flows/{slug}/start", h.StartFlow)
r.Post("/flows/{slug}/respond", h.RespondFlow)
r.Get("/flows/complete", h.CompleteFlow)
r.Post("/flows/complete", h.CompleteFlow)
return r
}
@ -83,7 +89,7 @@ func (h *Handler) StartFlow(w http.ResponseWriter, r *http.Request) {
if !done {
setFlowSessionCookie(w, sessionID)
if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
forwardFlowCookies(w, cookies)
forwardFlowCookies(w, r, cookies)
}
} else {
clearFlowSessionCookie(w)
@ -144,14 +150,16 @@ func (h *Handler) RespondFlow(w http.ResponseWriter, r *http.Request) {
if denied {
clearFlowSessionCookie(w)
h.flows.Delete(r.Context(), sessionID)
} else if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
// Push authenticated Authentik session to browser before OIDC bridge.
forwardFlowCookies(w, r, cookies)
}
// Successful auth: keep session cookie until /flows/complete.
} else {
clearFlowSessionCookie(w)
h.flows.Delete(r.Context(), sessionID)
}
} else if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
forwardFlowCookies(w, cookies)
forwardFlowCookies(w, r, cookies)
}
writeFlowJSON(w, http.StatusOK, flowStartResponse{

View File

@ -37,7 +37,7 @@ func TestValidateFlowSlugRejected(t *testing.T) {
func TestRespondFlowMissingCookie(t *testing.T) {
t.Parallel()
h := NewHandler("http://127.0.0.1:1", "http://localhost:3004", nil)
h := NewHandler("http://127.0.0.1:1", "http://localhost:3004", "", "", "", nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/flows/ulti-enrollment/respond", bytes.NewReader([]byte(`{"payload":{"component":"x"}}`)))
rctx := chi.NewRouteContext()
@ -52,7 +52,33 @@ func TestRespondFlowMissingCookie(t *testing.T) {
func TestBuildLoginRedirectURL(t *testing.T) {
t.Parallel()
got := buildLoginRedirectURL("http://localhost:3004", "/mail/inbox")
want := "http://localhost:3004/api/auth/login?returnTo=%2Fmail%2Finbox"
want := "http://localhost:3004/api/auth/login?bridge=1&returnTo=%2Fmail%2Finbox"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestBuildOIDCBridgeConfig(t *testing.T) {
t.Parallel()
cfg := buildOIDCBridgeConfig(
"http://nginx/auth/application/o/ulti/",
"ulti-backend",
"changeme",
"https://dev.ultispace.fr/mail",
"http://authentik-server:9000",
)
if cfg.redirectURI != "https://dev.ultispace.fr/api/auth/callback" {
t.Fatalf("redirectURI = %q", cfg.redirectURI)
}
if cfg.authorizeURL != "http://authentik-server:9000/auth/application/o/authorize/" {
t.Fatalf("authorizeURL = %q", cfg.authorizeURL)
}
}
func TestBuildLoginRedirectURLStripsMailPrefix(t *testing.T) {
t.Parallel()
got := buildLoginRedirectURL("https://dev.ultispace.fr/mail", "/mail/inbox")
want := "https://dev.ultispace.fr/api/auth/login?bridge=1&returnTo=%2Fmail%2Finbox"
if got != want {
t.Fatalf("got %q want %q", got, want)
}

View File

@ -0,0 +1,76 @@
package authapi
import (
"net/http"
"net/url"
"strings"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
)
type oauthFlowContextResponse struct {
FlowQuery string `json:"flowQuery"`
}
// PrepareOAuthFlowContext returns the Authentik flow executor query string that binds
// embedded authentication to the pending OIDC authorize request (see Authentik flow-executor docs).
func (h *Handler) PrepareOAuthFlowContext(w http.ResponseWriter, r *http.Request) {
if h.oidcClientID == "" {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "oidc_not_configured", "OIDC client id not configured", nil)
return
}
returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo"))
if returnTo == "" {
returnTo = "/mail/inbox"
}
if !strings.HasPrefix(returnTo, "/") || strings.HasPrefix(returnTo, "//") {
apiresponse.WriteError(w, r, http.StatusBadRequest, "invalid_return_to", "returnTo must be a relative path", nil)
return
}
verifier, challenge, err := newPKCEPair()
if err != nil {
apiresponse.WriteError(w, r, http.StatusInternalServerError, "pkce_failed", err.Error(), nil)
return
}
state, err := randomOAuthState()
if err != nil {
apiresponse.WriteError(w, r, http.StatusInternalServerError, "state_failed", err.Error(), nil)
return
}
setOAuthBridgeCookies(w, r, verifier, state, returnTo)
flowQuery := buildOAuthFlowQuery(h.appURL, h.oidcClientID, state, challenge)
apiresponse.WriteJSON(w, http.StatusOK, oauthFlowContextResponse{FlowQuery: flowQuery})
}
func buildOAuthFlowQuery(appURL, clientID, state, codeChallenge string) string {
base := suiteAuthAppOrigin(appURL)
oauthParams := url.Values{}
oauthParams.Set("client_id", clientID)
oauthParams.Set("redirect_uri", base+"/api/auth/callback")
oauthParams.Set("response_type", "code")
oauthParams.Set("scope", "openid profile email offline_access")
oauthParams.Set("state", state)
oauthParams.Set("code_challenge", codeChallenge)
oauthParams.Set("code_challenge_method", "S256")
authorizePath := "/auth/application/o/authorize/?" + oauthParams.Encode()
flowParams := url.Values{}
flowParams.Set("next", authorizePath)
return flowParams.Encode()
}
func buildOAuthAuthorizeURL(appURL, clientID, state, codeChallenge string) string {
base := suiteAuthAppOrigin(appURL)
oauthParams := url.Values{}
oauthParams.Set("client_id", clientID)
oauthParams.Set("redirect_uri", base+"/api/auth/callback")
oauthParams.Set("response_type", "code")
oauthParams.Set("scope", "openid profile email offline_access")
oauthParams.Set("state", state)
oauthParams.Set("code_challenge", codeChallenge)
oauthParams.Set("code_challenge_method", "S256")
return base + "/auth/application/o/authorize/?" + oauthParams.Encode()
}

View File

@ -0,0 +1,26 @@
package authapi
import (
"strings"
"testing"
)
func TestBuildOAuthFlowQuery(t *testing.T) {
t.Parallel()
q := buildOAuthFlowQuery(
"https://dev.ultispace.fr/mail",
"ulti-backend",
"state123",
"challenge456",
)
for _, part := range []string{
"next=",
"ulti-backend",
"state123",
"challenge456",
} {
if !strings.Contains(q, part) {
t.Fatalf("flow query missing %q: %q", part, q)
}
}
}

View File

@ -0,0 +1,306 @@
package authapi
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"github.com/ultisuite/ulti-backend/internal/authentik"
)
type oidcBridgeConfig struct {
clientID string
clientSecret string
redirectURI string
authorizeURL string
authentikBase string
}
type oidcBridge struct {
cfg oidcBridgeConfig
}
func newOIDCBridge(cfg oidcBridgeConfig) *oidcBridge {
return &oidcBridge{cfg: cfg}
}
func buildOIDCBridgeConfig(_issuer, clientID, clientSecret, appURL, authentikAPIURL string) oidcBridgeConfig {
base := suiteAuthAppOrigin(appURL)
redirectURI := base + "/api/auth/callback"
authentikBase := authentik.APIBaseURL(authentikAPIURL)
return oidcBridgeConfig{
clientID: strings.TrimSpace(clientID),
clientSecret: strings.TrimSpace(clientSecret),
redirectURI: redirectURI,
authorizeURL: strings.TrimSuffix(authentikBase, "/") + "/application/o/authorize/",
authentikBase: authentikBase,
}
}
// callbackURL performs a server-side OIDC authorize using the Authentik session from the embedded flow.
func (b *oidcBridge) callbackURL(ctx context.Context, cookies []authentik.SerializedCookie) (callbackURL, pkceVerifier, state string, err error) {
if b == nil || b.cfg.clientID == "" || b.cfg.clientSecret == "" || b.cfg.redirectURI == "" {
return "", "", "", fmt.Errorf("oidc bridge not configured")
}
if !authentik.SessionAuthenticated(cookies) {
return "", "", "", fmt.Errorf("authentik session not authenticated")
}
verifier, challenge, err := newPKCEPair()
if err != nil {
return "", "", "", err
}
state, err = randomOAuthState()
if err != nil {
return "", "", "", err
}
jar, err := cookiejar.New(nil)
if err != nil {
return "", "", "", err
}
cookieURL, err := url.Parse(strings.TrimSuffix(b.cfg.authentikBase, "/") + "/")
if err != nil {
return "", "", "", err
}
jar.SetCookies(cookieURL, authentik.BrowserAuthentikCookies(cookies))
var captured *url.URL
client := &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
slog.Info("oidc bridge redirect", "to", redactQuery(req.URL))
if strings.HasPrefix(req.URL.String(), b.cfg.redirectURI) {
captured = req.URL
return http.ErrUseLastResponse
}
if len(via) >= 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/<slug>/ and /if/flow/<slug>/
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()
}

31
internal/api/auth/pkce.go Normal file
View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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)
}
}

View File

@ -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 == "" {

View File

@ -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")
}
}

View File

@ -6,6 +6,9 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@ -22,6 +25,7 @@ type storedFlowSession struct {
Cookies []SerializedCookie `json:"cookies"`
CreatedAt time.Time `json:"createdAt"`
Completed bool `json:"completed,omitempty"`
OAuthCallback string `json:"oauthCallback,omitempty"`
}
type flowSessionEntry struct {
@ -29,6 +33,7 @@ type flowSessionEntry struct {
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
}
// CompleteSession returns cookies from a completed flow and removes the session.
func (s *FlowSessionStore) CompleteSession(ctx context.Context, sessionID, slug string) ([]SerializedCookie, error) {
entry, err := s.load(ctx, sessionID)
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
}
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.
@ -152,6 +254,7 @@ func (s *FlowSessionStore) save(ctx context.Context, sessionID string, entry *fl
Cookies: entry.cookies,
CreatedAt: entry.createdAt,
Completed: entry.completed,
OAuthCallback: entry.oauthCallback,
}
raw, err := json.Marshal(stored)
if err != nil {
@ -182,6 +285,7 @@ func (s *FlowSessionStore) load(ctx context.Context, sessionID string) (*flowSes
cookies: stored.Cookies,
createdAt: stored.CreatedAt,
completed: stored.Completed,
oauthCallback: stored.OAuthCallback,
}
s.mu.Lock()
s.items[sessionID] = entry

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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