feat(authentik): enhance OIDC flow with new logout redirect and branding support
- 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:
parent
f7ef89fa82
commit
525edb188a
@ -28,20 +28,6 @@ entries:
|
|||||||
placeholder_expression: false
|
placeholder_expression: false
|
||||||
order: 0
|
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
|
- model: authentik_stages_prompt.prompt
|
||||||
id: ulti-enroll-field-email-sync
|
id: ulti-enroll-field-email-sync
|
||||||
identifiers:
|
identifiers:
|
||||||
@ -128,7 +114,6 @@ entries:
|
|||||||
attrs:
|
attrs:
|
||||||
fields:
|
fields:
|
||||||
- !KeyOf ulti-enroll-field-email
|
- !KeyOf ulti-enroll-field-email
|
||||||
- !KeyOf ulti-enroll-field-domain-hint
|
|
||||||
- !KeyOf ulti-enroll-field-email-sync
|
- !KeyOf ulti-enroll-field-email-sync
|
||||||
- !KeyOf ulti-enroll-field-password
|
- !KeyOf ulti-enroll-field-password
|
||||||
- !KeyOf ulti-enroll-field-password-repeat
|
- !KeyOf ulti-enroll-field-password-repeat
|
||||||
|
|||||||
31
deploy/authentik/blueprints/06-ulti-oidc-logout.yaml
Normal file
31
deploy/authentik/blueprints/06-ulti-oidc-logout.yaml
Normal 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
|
||||||
@ -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
425
deploy/authentik/branding/ulti-authentik.js
Normal file
425
deploy/authentik/branding/ulti-authentik.js
Normal 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@ -111,6 +111,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./authentik/blueprints:/blueprints/custom:ro
|
- ./authentik/blueprints:/blueprints/custom:ro
|
||||||
- ./authentik/templates:/templates: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-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-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
|
- ./authentik/branding/ultisuite-favicon.png:/web/dist/assets/branding/ultisuite-favicon.png:ro
|
||||||
|
|||||||
@ -271,7 +271,7 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
|
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;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $ultid_upstream ultid:8080;
|
set $ultid_upstream ultid:8080;
|
||||||
proxy_hide_header Access-Control-Allow-Origin;
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
@ -589,6 +589,39 @@ server {
|
|||||||
proxy_set_header Connection $connection_upgrade;
|
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/ {
|
location /auth/ {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $authentik_upstream authentik-server:9000;
|
set $authentik_upstream authentik-server:9000;
|
||||||
@ -600,11 +633,16 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
|
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_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_read_timeout 86400;
|
||||||
proxy_hide_header X-Frame-Options;
|
proxy_hide_header X-Frame-Options;
|
||||||
proxy_hide_header Content-Security-Policy;
|
proxy_hide_header Content-Security-Policy;
|
||||||
# Permet l’embed du portail Authentik dans la suite (même host + dev Next :3004).
|
# 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;
|
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/ {
|
location /meet/ {
|
||||||
@ -617,6 +655,23 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
|
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
|
# Public Nextcloud share links → UltiDrive viewer
|
||||||
@ -1043,6 +1098,45 @@ server {
|
|||||||
proxy_set_header Connection $connection_upgrade;
|
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 {
|
location ^~ /chat {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};
|
||||||
|
|||||||
@ -20,17 +20,12 @@ type flowCompleteResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) CompleteFlow(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CompleteFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
sessionID := readFlowSessionCookie(r)
|
returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo"))
|
||||||
if sessionID == "" {
|
if returnTo == "" && r.Body != nil {
|
||||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, "flow_session_missing", "flow session cookie required", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req flowCompleteRequest
|
var req flowCompleteRequest
|
||||||
if r.Body != nil {
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
returnTo = strings.TrimSpace(req.ReturnTo)
|
||||||
}
|
}
|
||||||
returnTo := strings.TrimSpace(req.ReturnTo)
|
|
||||||
if returnTo == "" {
|
if returnTo == "" {
|
||||||
returnTo = "/mail/inbox"
|
returnTo = "/mail/inbox"
|
||||||
}
|
}
|
||||||
@ -39,8 +34,14 @@ func (h *Handler) CompleteFlow(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sessionID := readFlowSessionCookie(r)
|
||||||
|
if sessionID == "" {
|
||||||
|
apiresponse.WriteError(w, r, http.StatusUnauthorized, "flow_session_missing", "flow session cookie required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
slug := FlowAuthentication
|
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 err != nil {
|
||||||
if errors.Is(err, authentik.ErrFlowSessionNotFound) {
|
if errors.Is(err, authentik.ErrFlowSessionNotFound) {
|
||||||
clearFlowSessionCookie(w)
|
clearFlowSessionCookie(w)
|
||||||
@ -60,28 +61,111 @@ func (h *Handler) CompleteFlow(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearFlowSessionCookie(w)
|
clearFlowSessionCookie(w)
|
||||||
setBrowserAuthentikCookies(w, cookies)
|
|
||||||
|
|
||||||
loginURL := buildLoginRedirectURL(h.appURL, returnTo)
|
if !authentik.SessionAuthenticated(cookies) {
|
||||||
apiresponse.WriteJSON(w, http.StatusOK, flowCompleteResponse{RedirectURL: loginURL})
|
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 {
|
func buildLoginRedirectURL(appURL, returnTo string) string {
|
||||||
base := strings.TrimRight(strings.TrimSpace(appURL), "/")
|
base := suiteAuthAppOrigin(appURL)
|
||||||
if base == "" {
|
|
||||||
base = "http://localhost:3004"
|
|
||||||
}
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("returnTo", returnTo)
|
params.Set("returnTo", returnTo)
|
||||||
|
params.Set("bridge", "1")
|
||||||
return base + "/api/auth/login?" + params.Encode()
|
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) {
|
for _, c := range authentik.BrowserAuthentikCookies(stored) {
|
||||||
|
if secure {
|
||||||
|
c.Secure = true
|
||||||
|
}
|
||||||
http.SetCookie(w, c)
|
http.SetCookie(w, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func forwardFlowCookies(w http.ResponseWriter, stored []authentik.SerializedCookie) {
|
func requestIsHTTPS(r *http.Request) bool {
|
||||||
setBrowserAuthentikCookies(w, stored)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,20 +33,26 @@ type Handler struct {
|
|||||||
flows *authentik.FlowSessionStore
|
flows *authentik.FlowSessionStore
|
||||||
limiter *FlowRateLimiter
|
limiter *FlowRateLimiter
|
||||||
appURL string
|
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{
|
return &Handler{
|
||||||
flows: authentik.NewFlowSessionStore(baseURL, rdb),
|
flows: authentik.NewFlowSessionStore(baseURL, rdb),
|
||||||
limiter: NewFlowRateLimiter(rdb),
|
limiter: NewFlowRateLimiter(rdb),
|
||||||
appURL: appURL,
|
appURL: appURL,
|
||||||
|
oidcClientID: strings.TrimSpace(oidcClientID),
|
||||||
|
oidc: newOIDCBridge(buildOIDCBridgeConfig(oidcIssuer, oidcClientID, oidcClientSecret, appURL, baseURL)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Routes() chi.Router {
|
func (h *Handler) Routes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
r.Get("/flows/authentication/context", h.PrepareOAuthFlowContext)
|
||||||
r.Post("/flows/{slug}/start", h.StartFlow)
|
r.Post("/flows/{slug}/start", h.StartFlow)
|
||||||
r.Post("/flows/{slug}/respond", h.RespondFlow)
|
r.Post("/flows/{slug}/respond", h.RespondFlow)
|
||||||
|
r.Get("/flows/complete", h.CompleteFlow)
|
||||||
r.Post("/flows/complete", h.CompleteFlow)
|
r.Post("/flows/complete", h.CompleteFlow)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@ -83,7 +89,7 @@ func (h *Handler) StartFlow(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !done {
|
if !done {
|
||||||
setFlowSessionCookie(w, sessionID)
|
setFlowSessionCookie(w, sessionID)
|
||||||
if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
|
if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
|
||||||
forwardFlowCookies(w, cookies)
|
forwardFlowCookies(w, r, cookies)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clearFlowSessionCookie(w)
|
clearFlowSessionCookie(w)
|
||||||
@ -144,14 +150,16 @@ func (h *Handler) RespondFlow(w http.ResponseWriter, r *http.Request) {
|
|||||||
if denied {
|
if denied {
|
||||||
clearFlowSessionCookie(w)
|
clearFlowSessionCookie(w)
|
||||||
h.flows.Delete(r.Context(), sessionID)
|
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 {
|
} else {
|
||||||
clearFlowSessionCookie(w)
|
clearFlowSessionCookie(w)
|
||||||
h.flows.Delete(r.Context(), sessionID)
|
h.flows.Delete(r.Context(), sessionID)
|
||||||
}
|
}
|
||||||
} else if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
|
} else if cookies, err := h.flows.SessionCookies(r.Context(), sessionID); err == nil {
|
||||||
forwardFlowCookies(w, cookies)
|
forwardFlowCookies(w, r, cookies)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFlowJSON(w, http.StatusOK, flowStartResponse{
|
writeFlowJSON(w, http.StatusOK, flowStartResponse{
|
||||||
|
|||||||
@ -37,7 +37,7 @@ func TestValidateFlowSlugRejected(t *testing.T) {
|
|||||||
|
|
||||||
func TestRespondFlowMissingCookie(t *testing.T) {
|
func TestRespondFlowMissingCookie(t *testing.T) {
|
||||||
t.Parallel()
|
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()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodPost, "/flows/ulti-enrollment/respond", bytes.NewReader([]byte(`{"payload":{"component":"x"}}`)))
|
req := httptest.NewRequest(http.MethodPost, "/flows/ulti-enrollment/respond", bytes.NewReader([]byte(`{"payload":{"component":"x"}}`)))
|
||||||
rctx := chi.NewRouteContext()
|
rctx := chi.NewRouteContext()
|
||||||
@ -52,7 +52,33 @@ func TestRespondFlowMissingCookie(t *testing.T) {
|
|||||||
func TestBuildLoginRedirectURL(t *testing.T) {
|
func TestBuildLoginRedirectURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
got := buildLoginRedirectURL("http://localhost:3004", "/mail/inbox")
|
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 {
|
if got != want {
|
||||||
t.Fatalf("got %q want %q", got, want)
|
t.Fatalf("got %q want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
76
internal/api/auth/oauth_context.go
Normal file
76
internal/api/auth/oauth_context.go
Normal 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()
|
||||||
|
}
|
||||||
26
internal/api/auth/oauth_context_test.go
Normal file
26
internal/api/auth/oauth_context_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
306
internal/api/auth/oidc_bridge.go
Normal file
306
internal/api/auth/oidc_bridge.go
Normal 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
31
internal/api/auth/pkce.go
Normal 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
|
||||||
|
}
|
||||||
28
internal/api/middleware/forwarded.go
Normal file
28
internal/api/middleware/forwarded.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
28
internal/api/middleware/forwarded_test.go
Normal file
28
internal/api/middleware/forwarded_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
@ -50,26 +51,44 @@ func NewFlowExecutor(baseURL, slug string) (*FlowExecutor, error) {
|
|||||||
func (fe *FlowExecutor) executorURL(query string) string {
|
func (fe *FlowExecutor) executorURL(query string) string {
|
||||||
u := fmt.Sprintf("%s/api/v3/flows/executor/%s/", fe.baseURL, fe.slug)
|
u := fmt.Sprintf("%s/api/v3/flows/executor/%s/", fe.baseURL, fe.slug)
|
||||||
if query != "" {
|
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
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChallenge starts or resumes a flow and returns the pending challenge.
|
// GetChallenge starts or resumes a flow and returns the pending challenge.
|
||||||
func (fe *FlowExecutor) GetChallenge(ctx context.Context, query string) (FlowChallenge, error) {
|
func (fe *FlowExecutor) GetChallenge(ctx context.Context, query string) (FlowChallenge, error) {
|
||||||
if err := fe.warmSession(ctx); err != nil {
|
// Establish the flow plan via the executor GET with the query string so the OAuth `next`
|
||||||
return nil, err
|
// 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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fe.executorURL(query), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
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)
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -106,6 +125,236 @@ func (fe *FlowExecutor) PostResponse(ctx context.Context, query string, payload
|
|||||||
return fe.doChallenge(req)
|
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 {
|
func (fe *FlowExecutor) cookieURL() *url.URL {
|
||||||
u, err := url.Parse(fe.baseURL)
|
u, err := url.Parse(fe.baseURL)
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" {
|
||||||
|
|||||||
38
internal/authentik/flow_executor_redirect_test.go
Normal file
38
internal/authentik/flow_executor_redirect_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,9 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -22,6 +25,7 @@ type storedFlowSession struct {
|
|||||||
Cookies []SerializedCookie `json:"cookies"`
|
Cookies []SerializedCookie `json:"cookies"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Completed bool `json:"completed,omitempty"`
|
Completed bool `json:"completed,omitempty"`
|
||||||
|
OAuthCallback string `json:"oauthCallback,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type flowSessionEntry struct {
|
type flowSessionEntry struct {
|
||||||
@ -29,6 +33,7 @@ type flowSessionEntry struct {
|
|||||||
cookies []SerializedCookie
|
cookies []SerializedCookie
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
completed bool
|
completed bool
|
||||||
|
oauthCallback string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlowSessionStore keeps Authentik flow executor sessions (memory + optional KeyDB).
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
entry.cookies = executor.ExportCookies()
|
entry.cookies = executor.ExportCookies()
|
||||||
done, denied := FlowDone(challenge)
|
challenge, err = s.advanceHeadlessStages(ctx, executor, query, challenge)
|
||||||
if done && !denied {
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry.cookies = executor.ExportCookies()
|
||||||
|
|
||||||
|
if SessionAuthenticated(entry.cookies) {
|
||||||
entry.completed = true
|
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 {
|
if err := s.save(ctx, sessionID, entry); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if FlowComponent(challenge) != "xak-flow-redirect" {
|
||||||
|
challenge = FlowChallenge{"component": "xak-flow-redirect"}
|
||||||
|
}
|
||||||
return challenge, nil
|
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 {
|
if err := s.save(ctx, sessionID, entry); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return challenge, nil
|
return challenge, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteSession returns cookies from a completed flow and removes the session.
|
func (s *FlowSessionStore) advanceHeadlessStages(ctx context.Context, executor *FlowExecutor, query string, challenge FlowChallenge) (FlowChallenge, error) {
|
||||||
func (s *FlowSessionStore) CompleteSession(ctx context.Context, sessionID, slug string) ([]SerializedCookie, error) {
|
const maxSteps = 8
|
||||||
entry, err := s.load(ctx, sessionID)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if entry.slug != slug {
|
||||||
return nil, ErrFlowSessionSlugMismatch
|
return nil, "", ErrFlowSessionSlugMismatch
|
||||||
}
|
}
|
||||||
if !entry.completed {
|
if !entry.completed {
|
||||||
return nil, ErrFlowSessionNotCompleted
|
return nil, "", ErrFlowSessionNotCompleted
|
||||||
}
|
}
|
||||||
cookies := entry.cookies
|
cookies := entry.cookies
|
||||||
|
callback := entry.oauthCallback
|
||||||
s.delete(ctx, sessionID)
|
s.delete(ctx, sessionID)
|
||||||
return cookies, nil
|
return cookies, callback, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionCookies returns current Authentik cookies for an active session.
|
// 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,
|
Cookies: entry.cookies,
|
||||||
CreatedAt: entry.createdAt,
|
CreatedAt: entry.createdAt,
|
||||||
Completed: entry.completed,
|
Completed: entry.completed,
|
||||||
|
OAuthCallback: entry.oauthCallback,
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(stored)
|
raw, err := json.Marshal(stored)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -182,6 +285,7 @@ func (s *FlowSessionStore) load(ctx context.Context, sessionID string) (*flowSes
|
|||||||
cookies: stored.Cookies,
|
cookies: stored.Cookies,
|
||||||
createdAt: stored.CreatedAt,
|
createdAt: stored.CreatedAt,
|
||||||
completed: stored.Completed,
|
completed: stored.Completed,
|
||||||
|
oauthCallback: stored.OAuthCallback,
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.items[sessionID] = entry
|
s.items[sessionID] = entry
|
||||||
|
|||||||
21
internal/authentik/flow_session_store_test.go
Normal file
21
internal/authentik/flow_session_store_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/authentik/session_cookie.go
Normal file
37
internal/authentik/session_cookie.go
Normal 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
|
||||||
|
}
|
||||||
15
internal/authentik/session_cookie_test.go
Normal file
15
internal/authentik/session_cookie_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -344,6 +344,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
|
|||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(httpcors.Middleware(cfg))
|
r.Use(httpcors.Middleware(cfg))
|
||||||
|
r.Use(middleware.ForwardedHeaders)
|
||||||
r.Use(middleware.TraceID)
|
r.Use(middleware.TraceID)
|
||||||
r.Use(observability.HTTPMetrics)
|
r.Use(observability.HTTPMetrics)
|
||||||
r.Use(middleware.Logging)
|
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/mail/addresses/check", mailHandler.CheckAddressAvailability)
|
||||||
r.Get("/api/v1/migration/invite", migrationHandler.GetInvite)
|
r.Get("/api/v1/migration/invite", migrationHandler.GetInvite)
|
||||||
r.Post("/internal/provision/user", provisionHandler.ProvisionUser)
|
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 driveHandler *drive.Handler
|
||||||
var driveSvc *drive.Service
|
var driveSvc *drive.Service
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user