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
|
||||
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
|
||||
|
||||
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:
|
||||
- ./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
|
||||
|
||||
@ -271,7 +271,7 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
|
||||
}
|
||||
location ^~ /api/v1/calendar/ {
|
||||
location ^~ /api/v1/calendar {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $ultid_upstream ultid:8080;
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
@ -589,6 +589,39 @@ server {
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
# Authentik brand CSS — bind-mount on authentik-server; must not sit behind CF 4h cache.
|
||||
location = /auth/static/dist/custom.css {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $authentik_upstream authentik-server:9000;
|
||||
proxy_pass http://$authentik_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
|
||||
proxy_hide_header Cache-Control;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:3004 http://127.0.0.1:3004" always;
|
||||
}
|
||||
|
||||
location = /auth/static/dist/custom.js {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $authentik_upstream authentik-server:9000;
|
||||
proxy_pass http://$authentik_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
|
||||
proxy_hide_header Cache-Control;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:3004 http://127.0.0.1:3004" always;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $authentik_upstream authentik-server:9000;
|
||||
@ -600,11 +633,16 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO};
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
# sub_filter ne fonctionne pas sur les réponses gzip — désactiver la compression amont.
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_read_timeout 86400;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
# Permet l’embed du portail Authentik dans la suite (même host + dev Next :3004).
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' http://localhost:3004 http://127.0.0.1:3004" always;
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html;
|
||||
sub_filter '<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};
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
var req flowCompleteRequest
|
||||
if r.Body != nil {
|
||||
returnTo := strings.TrimSpace(r.URL.Query().Get("returnTo"))
|
||||
if returnTo == "" && r.Body != nil {
|
||||
var req flowCompleteRequest
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
@ -30,23 +30,29 @@ var allowedFlowSlugs = map[string]struct{}{
|
||||
|
||||
// Handler exposes public Authentik flow executor endpoints for custom auth UI.
|
||||
type Handler struct {
|
||||
flows *authentik.FlowSessionStore
|
||||
limiter *FlowRateLimiter
|
||||
appURL string
|
||||
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,
|
||||
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{
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
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"
|
||||
"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 == "" {
|
||||
|
||||
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/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@ -18,17 +21,19 @@ const (
|
||||
)
|
||||
|
||||
type storedFlowSession struct {
|
||||
Slug string `json:"slug"`
|
||||
Cookies []SerializedCookie `json:"cookies"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Completed bool `json:"completed,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Cookies []SerializedCookie `json:"cookies"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Completed bool `json:"completed,omitempty"`
|
||||
OAuthCallback string `json:"oauthCallback,omitempty"`
|
||||
}
|
||||
|
||||
type flowSessionEntry struct {
|
||||
slug string
|
||||
cookies []SerializedCookie
|
||||
createdAt time.Time
|
||||
completed bool
|
||||
slug string
|
||||
cookies []SerializedCookie
|
||||
createdAt time.Time
|
||||
completed bool
|
||||
oauthCallback string
|
||||
}
|
||||
|
||||
// FlowSessionStore keeps Authentik flow executor sessions (memory + optional KeyDB).
|
||||
@ -96,35 +101,132 @@ func (s *FlowSessionStore) Respond(ctx context.Context, sessionID, slug, query s
|
||||
return nil, err
|
||||
}
|
||||
entry.cookies = executor.ExportCookies()
|
||||
done, denied := FlowDone(challenge)
|
||||
if done && !denied {
|
||||
challenge, err = s.advanceHeadlessStages(ctx, executor, query, challenge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.cookies = executor.ExportCookies()
|
||||
|
||||
if SessionAuthenticated(entry.cookies) {
|
||||
entry.completed = true
|
||||
// If the flow's terminal redirect is an OIDC authorize continuation, follow it within the
|
||||
// live authenticated jar to capture the authorization code (the only reliable way to bridge
|
||||
// an API-driven session to OIDC; replaying the session cookie elsewhere is rejected).
|
||||
if to, ok := challenge["to"].(string); ok && isOAuthAuthorizeRedirect(to) {
|
||||
callback, capErr := executor.CaptureOAuthCallback(ctx, to)
|
||||
if capErr != nil {
|
||||
slog.Warn("capture oauth callback failed", "err", capErr.Error())
|
||||
} else {
|
||||
entry.oauthCallback = callback
|
||||
slog.Info("captured oauth callback", "ok", callback != "")
|
||||
}
|
||||
}
|
||||
if err := s.save(ctx, sessionID, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if FlowComponent(challenge) != "xak-flow-redirect" {
|
||||
challenge = FlowChallenge{"component": "xak-flow-redirect"}
|
||||
}
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
done, denied := FlowDone(challenge)
|
||||
if done && !denied {
|
||||
return nil, fmt.Errorf("authentik flow finished without authenticated session")
|
||||
}
|
||||
if err := s.save(ctx, sessionID, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
func (s *FlowSessionStore) advanceHeadlessStages(ctx context.Context, executor *FlowExecutor, query string, challenge FlowChallenge) (FlowChallenge, error) {
|
||||
const maxSteps = 8
|
||||
for step := 0; step < maxSteps; step++ {
|
||||
if SessionAuthenticated(executor.ExportCookies()) {
|
||||
return challenge, nil
|
||||
}
|
||||
if _, denied := FlowDone(challenge); denied {
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
switch FlowComponent(challenge) {
|
||||
case "ak-stage-user-login":
|
||||
var err error
|
||||
challenge, err = executor.PostResponse(ctx, query, map[string]any{"component": "ak-stage-user-login"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "xak-flow-redirect":
|
||||
// Resume pending stages before following terminal redirects (/auth/ resets session).
|
||||
next, err := executor.GetChallenge(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if comp := FlowComponent(next); comp != "" && comp != "xak-flow-redirect" {
|
||||
challenge = next
|
||||
continue
|
||||
}
|
||||
if to, ok := challenge["to"].(string); ok && isTerminalFlowRedirect(to) {
|
||||
loginChallenge, loginErr := executor.PostResponse(ctx, query, map[string]any{"component": "ak-stage-user-login"})
|
||||
if loginErr == nil {
|
||||
challenge = loginChallenge
|
||||
continue
|
||||
}
|
||||
return challenge, nil
|
||||
}
|
||||
if to, ok := challenge["to"].(string); ok && strings.TrimSpace(to) != "" {
|
||||
if err := executor.FollowFlowRedirect(ctx, to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
next, err = executor.GetChallenge(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challenge = next
|
||||
default:
|
||||
return challenge, nil
|
||||
}
|
||||
}
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
func isOAuthAuthorizeRedirect(to string) bool {
|
||||
return strings.Contains(to, "/application/o/authorize")
|
||||
}
|
||||
|
||||
func isTerminalFlowRedirect(to string) bool {
|
||||
to = strings.TrimSpace(to)
|
||||
if to == "" || to == "/auth/" || to == "/auth" {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(strings.TrimSuffix(to, "/"), "/auth")
|
||||
}
|
||||
|
||||
// CompleteSession returns cookies from a completed flow and removes the session.
|
||||
func (s *FlowSessionStore) CompleteSession(ctx context.Context, sessionID, slug string) ([]SerializedCookie, error) {
|
||||
cookies, _, err := s.CompleteSessionOAuth(ctx, sessionID, slug)
|
||||
return cookies, err
|
||||
}
|
||||
|
||||
// CompleteSessionOAuth returns the completed flow's cookies plus any captured OIDC callback URL
|
||||
// (carrying the authorization code) and removes the session.
|
||||
func (s *FlowSessionStore) CompleteSessionOAuth(ctx context.Context, sessionID, slug string) ([]SerializedCookie, string, error) {
|
||||
entry, err := s.load(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
if entry.slug != slug {
|
||||
return nil, ErrFlowSessionSlugMismatch
|
||||
return nil, "", ErrFlowSessionSlugMismatch
|
||||
}
|
||||
if !entry.completed {
|
||||
return nil, ErrFlowSessionNotCompleted
|
||||
return nil, "", ErrFlowSessionNotCompleted
|
||||
}
|
||||
cookies := entry.cookies
|
||||
callback := entry.oauthCallback
|
||||
s.delete(ctx, sessionID)
|
||||
return cookies, nil
|
||||
return cookies, callback, nil
|
||||
}
|
||||
|
||||
// SessionCookies returns current Authentik cookies for an active session.
|
||||
@ -148,10 +250,11 @@ func (s *FlowSessionStore) save(ctx context.Context, sessionID string, entry *fl
|
||||
return nil
|
||||
}
|
||||
stored := storedFlowSession{
|
||||
Slug: entry.slug,
|
||||
Cookies: entry.cookies,
|
||||
CreatedAt: entry.createdAt,
|
||||
Completed: entry.completed,
|
||||
Slug: entry.slug,
|
||||
Cookies: entry.cookies,
|
||||
CreatedAt: entry.createdAt,
|
||||
Completed: entry.completed,
|
||||
OAuthCallback: entry.oauthCallback,
|
||||
}
|
||||
raw, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
@ -178,10 +281,11 @@ func (s *FlowSessionStore) load(ctx context.Context, sessionID string) (*flowSes
|
||||
return nil, ErrFlowSessionNotFound
|
||||
}
|
||||
entry := &flowSessionEntry{
|
||||
slug: stored.Slug,
|
||||
cookies: stored.Cookies,
|
||||
createdAt: stored.CreatedAt,
|
||||
completed: stored.Completed,
|
||||
slug: stored.Slug,
|
||||
cookies: stored.Cookies,
|
||||
createdAt: stored.CreatedAt,
|
||||
completed: stored.Completed,
|
||||
oauthCallback: stored.OAuthCallback,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.items[sessionID] = entry
|
||||
|
||||
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.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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user