mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
fix(web-app-friendica): patch upstream ldapauth autocreate + variant-aware playwright
Root cause: upstream friendica-addons/ldapauth's `ldap_createaccount` returns the raw `User::create()` result (`['user' => UserRow, 'password' => '']`), but the `authenticate` hook only sets `authenticated = 1` when `$user['uid']` is non-empty — never true for the autocreate path. First- login LDAP users (e.g. biber) were inserted into `friendica.user` but the session was never started, so /network kept serving the login form. Encoded as an idempotent in-container patch task in `tasks/02_ldap.yml` + `files/patch_ldapauth_autocreate.sh` (reports PATCHED on first run, ALREADY on subsequent runs). Runs whenever LDAP is wired, gated by FRIENDICA_LDAP_ENABLED. Other changes in the same stack: - meta/services.yml: drop keycloakpassword addon (oauth2-proxy in front owns SSO); ldap.enabled now follows svc-db-openldap group presence so the variant matrix can disable it without a static override. - tasks/01_core.yml + tasks/06_user_bootstrap.yml: pre-create the administrator only in LDAP-disabled variants — when LDAP is wired, ldapauth materialises the row on first admin login. - files/playwright.spec.js: variant-aware spec replacing the parked test.skip cases. Three tests now run: guest flow (always), admin native login (picks double-login vs direct based on OAUTH2_SERVICE_ENABLED), biber + admin side-by-side in isolated contexts. PERSONA_* still blocked — friendica has no header-trusted autocreate path. - templates/playwright.env.j2: expose BIBER_* + service-gate flags. - inventories/development/default.yml: biber email aligned with the oauth2-proxy `email_domains = infinito.example` filter so biber's Keycloak login isn't 403'd at the proxy. - TODO.md: SAML migration outlined as the long-term replacement for the double-login (currently second hop silently bypasses Keycloak 2FA). Drive-by: trim double-space before `# nocheck` in roles/sys-aur/meta/services.yml. Verified on VARIANT=0 (oauth2 + ldap + oidc all enabled): three playwright tests green, failed=0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b8a372ded7
commit
9763cd8a4a
12 files changed
+296
-131
No files matched your search
@@ -35,7 +35,7 @@ users:
|
||||
firstname: "Harry"
|
||||
lastname: "Beaver"
|
||||
website: "https://www.infinito.nexus/"
|
||||
email: "contact@infinito.nexus"
|
||||
email: "biber@infinito.example"
|
||||
gpg: "DUMMY-GPG-KEY-NOT-SET"
|
||||
password: "VeryUnsecurePassword"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
aur:
|
||||
lifecycle: beta
|
||||
repository: https://aur.archlinux.org/yay.git # nocheck: url
|
||||
repository: https://aur.archlinux.org/yay.git # nocheck: url
|
||||
ref: master
|
||||
@@ -0,0 +1,50 @@
|
||||
# TODOs
|
||||
|
||||
## Migrate auth to the `saml` addon (replaces oauth2-proxy + ldapauth + double-login)
|
||||
|
||||
**Why:** Friendica currently authenticates in two steps: oauth2-proxy gates
|
||||
the vhost (Keycloak-Authorization-Code-Flow, 2FA-enforceable), then the user
|
||||
must fill Friendica's own `/login` form because stock Friendica has **no
|
||||
header-trusted auto-login** — Owa.php / OAuth/Token.php only read
|
||||
`HTTP_AUTHORIZATION` for API endpoints, never for the web session. The
|
||||
ldapauth addon stays enabled solely as a first-login bootstrap that
|
||||
materialises the `friendica.user` row from openldap.
|
||||
|
||||
The double-login is UX-hostile (creds typed twice) and the second hop
|
||||
silently bypasses any 2FA Keycloak enforced on the first hop — anyone with
|
||||
the LDAP password can reach the app even when WebAuthn/OTP is required at
|
||||
the IdP. Friendica's stock `saml` addon (already on disk under
|
||||
`/var/www/html/addon/saml/`) supports a SAML2 AuthnRequest → IdP-initiated
|
||||
SSO with auto-account-creation, which collapses the chain to a single
|
||||
2FA-gated round-trip.
|
||||
|
||||
**Migration outline (~4-6h):**
|
||||
|
||||
1. Enable `addons.saml: {}` in `meta/services.yml` (and drop `addons.ldapauth: {}`
|
||||
plus `services.oauth2` once SAML autocreate is verified).
|
||||
2. Template `saml.config.php.j2` analogous to `ldapauth.config.php.j2`:
|
||||
IdP-metadata-URL, SP-Entity-ID, SP-ACS-URL, NameID format, attribute map
|
||||
(`email`/`firstName`/`lastName`/`groups`), signing/encryption cert paths.
|
||||
3. Mount the rendered `saml.config.php` in `compose.yml.j2` (analogous to
|
||||
ldapauth).
|
||||
4. Provision a SAML client in Keycloak (extend `roles/web-app-keycloak`'s
|
||||
client-provisioning logic which currently only handles OIDC clients).
|
||||
Attribute mappers, SP-metadata import, signing certificate alignment.
|
||||
5. Drop `services.oauth2.enabled` for friendica — SAML handles SSO end-to-
|
||||
end, oauth2-proxy in front would double-gate.
|
||||
6. Rewrite `files/playwright.spec.js` to use the generic admin/biber
|
||||
persona flows (remove `PERSONA_*_BLOCKED=true` from
|
||||
`templates/playwright.env.j2`, drop the bespoke `loginViaOauth2ProxyAndFriendica`
|
||||
helper, drop the FRIENDICA_BASE_URL env var).
|
||||
|
||||
**Risks / unknowns:**
|
||||
|
||||
- Friendica's `saml` addon is community-maintained — last-commit recency
|
||||
AND config-format compatibility need verification before committing to
|
||||
it as the canonical SSO path.
|
||||
- SAML cert lifecycle (rotation, alignment between Friendica SP and
|
||||
Keycloak IdP) is a new maintenance burden — clarify ownership before
|
||||
shipping.
|
||||
- First-login autocreate via SAML's `auto_register` option must be tested
|
||||
with both the `administrator` and `biber` openldap-sourced identities
|
||||
before ldapauth is removed.
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Patch the upstream friendica-addons/ldapauth `ldap_createaccount` so
|
||||
# the autocreate path returns the user-row shape the `authenticate`
|
||||
# hook expects.
|
||||
#
|
||||
# Upstream returns the bare `User::create()` result, which is
|
||||
# `['user' => UserRow, 'password' => '']`. The hook then evaluates
|
||||
# `if (!empty($user['uid']))` and silently bails out — first-login
|
||||
# users get inserted into `friendica.user` but never receive
|
||||
# `authenticated = 1`, so /network keeps serving the login form
|
||||
# instead of starting a session. We unwrap `$user['user']` so the uid
|
||||
# is visible to the hook.
|
||||
#
|
||||
# Idempotent: a second run sees the patched line and exits "ALREADY".
|
||||
# Retries are safe across `compose up` restarts because the addon
|
||||
# tree is image-baked, not volume-mounted, so the upstream content
|
||||
# returns whenever the friendica image is re-pulled.
|
||||
#
|
||||
# Usage:
|
||||
# patch_ldapauth_autocreate.sh EXEC_PREFIX LDAPAUTH_PATH
|
||||
# EXEC_PREFIX full container-exec command prefix as a single arg
|
||||
# (typical: 'docker compose exec -T --user root
|
||||
# friendica')
|
||||
# LDAPAUTH_PATH absolute path of ldapauth.php inside the container
|
||||
set -euo pipefail
|
||||
|
||||
EXEC="$1"
|
||||
P="$2"
|
||||
|
||||
ORIG=" return \$user;"
|
||||
NEW=" return \$user['user'] ?? \$user;"
|
||||
|
||||
if $EXEC grep -qF "$NEW" "$P"; then
|
||||
echo "ALREADY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! $EXEC grep -qF "$ORIG" "$P"; then
|
||||
echo "NO-MATCH ldap_createaccount return-line shape changed upstream"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use python to avoid shell-escaping the single quotes inside sed/awk
|
||||
# replacement text — friendica image ships python3 via Composer base.
|
||||
$EXEC python3 -c "
|
||||
import sys
|
||||
p='$P'
|
||||
src=open(p).read()
|
||||
new=src.replace('''$ORIG''', '''$NEW''', 1)
|
||||
open(p, 'w').write(new)
|
||||
print('PATCHED')
|
||||
"
|
||||
@@ -1,158 +1,155 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const { decodeDotenvQuotedValue, runAdminFlow, runBiberFlow, runGuestFlow } = require("./personas");
|
||||
const { decodeDotenvQuotedValue, performKeycloakLoginForm, runGuestFlow } = require("./personas");
|
||||
const { isServiceEnabled, skipUnlessServiceEnabled } = require("./service-gating");
|
||||
|
||||
test.use({
|
||||
ignoreHTTPSErrors: true
|
||||
});
|
||||
|
||||
const friendicaBaseUrl = decodeDotenvQuotedValue(process.env.FRIENDICA_BASE_URL);
|
||||
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN);
|
||||
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
|
||||
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
|
||||
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
|
||||
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
|
||||
|
||||
// Friendica authenticates users directly against its local DB. The openldap
|
||||
// bridge is provided by the `ldapauth` addon: users typed into the Friendica
|
||||
// login form are resolved against the shared openldap server, which already
|
||||
// contains `administrator` and `biber`. No OIDC redirect step is involved.
|
||||
async function performFriendicaLogin(page, baseUrl, username, password) {
|
||||
await page.goto(`${baseUrl}/login`, { waitUntil: "domcontentloaded" });
|
||||
// Variant matrix (see meta/variants.yml):
|
||||
// v0 oauth2 + ldap + oidc all enabled — double-login pattern below
|
||||
// v1 every dynamic flag disabled — only guest test runs
|
||||
// v2 ldap enabled, oauth2 + oidc off — direct in-app login (no Keycloak hop)
|
||||
// All three are selected at runtime by inspecting `OAUTH2_SERVICE_ENABLED`
|
||||
// and `LDAP_SERVICE_ENABLED` — the spec stays one file across variants.
|
||||
|
||||
const usernameField = page.locator("input[name='username']");
|
||||
const passwordField = page.locator("input[name='password']");
|
||||
async function loginViaFriendicaForm(page, username, password) {
|
||||
const passwordField = page.locator("input[name='password']").first();
|
||||
await passwordField.waitFor({ state: "visible", timeout: 30_000 });
|
||||
|
||||
// Friendica renders a `btn form-button-search` submit in the topbar before
|
||||
// the main login form, so a generic `button[type='submit']` selector picks
|
||||
// up the search button. Scope the submit click to the form that owns the
|
||||
// password field, and accept any common submit label upstream may render
|
||||
// (`Sign in`, `Login`, `Anmelden`, …) to stay resilient against theme
|
||||
// / locale changes on Friendica's login template.
|
||||
const usernameField = page.locator("input[name='username']").first();
|
||||
const loginForm = page.locator("form").filter({ has: passwordField });
|
||||
const signInButton = loginForm
|
||||
.locator("button[type='submit'], input[type='submit']")
|
||||
.or(loginForm.getByRole("button", { name: /sign\s*in|log\s*in|anmelden|connexion|iniciar|entrar/i }))
|
||||
.first();
|
||||
|
||||
await usernameField.waitFor({ state: "visible", timeout: 60_000 });
|
||||
await usernameField.fill(username);
|
||||
await passwordField.fill(password);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForLoadState("domcontentloaded"),
|
||||
signInButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Wait until the post-login Friendica UI is rendered. The sidebar nav always
|
||||
// exposes the `#navbar-apps-menu` / profile button for authenticated users.
|
||||
async function waitForFriendicaHome(page) {
|
||||
// v0 path: oauth2-proxy gates the friendica vhost, so /login first redirects
|
||||
// through Keycloak — the browser-flow path that ENFORCES 2FA when the realm
|
||||
// has it configured. After the Keycloak round-trip Friendica still renders
|
||||
// its OWN login form: there is no header-trusted auto-login addon in stock
|
||||
// Friendica (Owa.php / OAuth.php only read HTTP_AUTHORIZATION for API auth,
|
||||
// never for the web session). Fill that second form so `ldapauth`'s
|
||||
// else-branch (User::getAuthenticationInfo + User::getById against the
|
||||
// pre-created friendica.user row) starts a Friendica session. See TODO.md
|
||||
// for the SAML migration that would collapse this back into a single hop.
|
||||
async function loginViaOauth2ProxyAndFriendica(page, username, password) {
|
||||
const baseUrl = friendicaBaseUrl.replace(/\/$/, "");
|
||||
await page.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
|
||||
// Step 1: oauth2-proxy intercepted the request — page is now on Keycloak.
|
||||
await performKeycloakLoginForm(page, username, password);
|
||||
// Wait until the browser hostname is the canonical friendica domain. The
|
||||
// naive `url().includes(canonicalDomain)` matches Keycloak's auth URL too
|
||||
// (the canonical domain appears in the `redirect_uri` query param), which
|
||||
// would let Step 2 run against Keycloak's form instead of Friendica's.
|
||||
await expect
|
||||
.poll(() => {
|
||||
try { return new URL(page.url()).hostname; } catch { return ""; }
|
||||
}, {
|
||||
timeout: 60_000,
|
||||
message: `Expected page hostname to become ${canonicalDomain} after Keycloak login`,
|
||||
})
|
||||
.toBe(canonicalDomain);
|
||||
|
||||
// Step 2: Friendica's own login form (post-oauth2-proxy).
|
||||
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
if (await page.locator("input[name='password']").first().isVisible({ timeout: 30_000 }).catch(() => false)) {
|
||||
await loginViaFriendicaForm(page, username, password);
|
||||
}
|
||||
|
||||
// Verify Friendica's authenticated surface is now rendered. Navigate to
|
||||
// an authenticated route explicitly so we don't race a same-page re-render
|
||||
// after the POST redirect — the frio / vier themes both expose
|
||||
// #topbar-first on /network; `a[href*='/logout']` is the cross-theme
|
||||
// fallback for less-canonical themes.
|
||||
await page.goto(`${baseUrl}/network`, { waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
const profileMenu = page.locator("#topbar-first, #navbar-apps-menu, a[href*='/logout']").first();
|
||||
await profileMenu.waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
|
||||
// Log out via the universal logout endpoint. Every app's nginx vhost intercepts
|
||||
// `location = /logout` and proxies it to web-svc-logout, which invalidates all
|
||||
// active sessions. `waitUntil: 'commit'` avoids ERR_ABORTED from the multi-
|
||||
// domain redirect chain the service triggers after session invalidation.
|
||||
async function friendicaLogout(page, baseUrl) {
|
||||
await page.goto(`${baseUrl.replace(/\/$/, "")}/logout`, { waitUntil: "commit" }).catch(() => {});
|
||||
// v2 path: no oauth2-proxy gating, friendica's `/login` is directly reachable.
|
||||
// `ldapauth`'s bind path authenticates against openldap, the pre-created
|
||||
// administrator row in friendica.user lets the else-branch return a uid →
|
||||
// hook sets authenticated=1 → session established.
|
||||
async function loginViaFriendicaDirect(page, username, password) {
|
||||
const baseUrl = friendicaBaseUrl.replace(/\/$/, "");
|
||||
await page.goto(`${baseUrl}/login`, { waitUntil: "domcontentloaded" });
|
||||
await loginViaFriendicaForm(page, username, password);
|
||||
await page.goto(`${baseUrl}/network`, { waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
const profileMenu = page.locator("#topbar-first, #navbar-apps-menu, a[href*='/logout']").first();
|
||||
await profileMenu.waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
expect(friendicaBaseUrl, "FRIENDICA_BASE_URL must be set in the Playwright env file").toBeTruthy();
|
||||
expect(adminUsername, "ADMIN_USERNAME must be set in the Playwright env file").toBeTruthy();
|
||||
expect(adminPassword, "ADMIN_PASSWORD must be set in the Playwright env file").toBeTruthy();
|
||||
expect(biberUsername, "BIBER_USERNAME must be set in the Playwright env file").toBeTruthy();
|
||||
expect(biberPassword, "BIBER_PASSWORD must be set in the Playwright env file").toBeTruthy();
|
||||
});
|
||||
|
||||
// Scenario I: dashboard → click Friendica → verify iframe → login as admin → logout.
|
||||
test("dashboard to friendica: admin login, verify ui, logout", async ({ page }) => {
|
||||
const expectedFriendicaBaseUrl = friendicaBaseUrl.replace(/\/$/, "");
|
||||
|
||||
// 1. Open dashboard and click the Friendica card.
|
||||
await page.goto("/");
|
||||
await page.getByRole("link", { name: /Explore Friendica/i }).click();
|
||||
|
||||
// 2. The dashboard embeds Friendica in a fullscreen iframe.
|
||||
await expect(page.locator("#main iframe")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// 3. Log in as administrator (navigates the top-level page, not the iframe).
|
||||
await performFriendicaLogin(page, expectedFriendicaBaseUrl, adminUsername, adminPassword);
|
||||
await waitForFriendicaHome(page);
|
||||
|
||||
// 4. Log out via the universal logout endpoint.
|
||||
await friendicaLogout(page, expectedFriendicaBaseUrl);
|
||||
|
||||
// 5. Confirm the session is gone — an unauthenticated request to /network
|
||||
// must not render the authenticated nav.
|
||||
await page.goto(`${expectedFriendicaBaseUrl}/network`, { waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
await expect(page.locator("a[href*='/logout']")).not.toBeAttached({ timeout: 10_000 });
|
||||
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
// Scenario II: biber and administrator each log in in isolated browser contexts,
|
||||
// both reach the authenticated Friendica UI, each logs out independently.
|
||||
//
|
||||
// The two contexts model two separate users on separate machines — no shared
|
||||
// cookies or sessions. This covers LDAP autocreate for a non-admin identity
|
||||
// alongside the admin path, and verifies that logout in one session does not
|
||||
// touch the other's session.
|
||||
test("friendica: biber and administrator log in side by side in isolated contexts", async ({ browser }) => {
|
||||
const expectedFriendicaBaseUrl = friendicaBaseUrl.replace(/\/$/, "");
|
||||
|
||||
const biberContext = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const adminContext = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
|
||||
try {
|
||||
const biberPage = await biberContext.newPage();
|
||||
await performFriendicaLogin(biberPage, expectedFriendicaBaseUrl, biberUsername, biberPassword);
|
||||
await waitForFriendicaHome(biberPage);
|
||||
|
||||
const adminPage = await adminContext.newPage();
|
||||
await performFriendicaLogin(adminPage, expectedFriendicaBaseUrl, adminUsername, adminPassword);
|
||||
await waitForFriendicaHome(adminPage);
|
||||
|
||||
// biber logs out first — admin session must stay alive.
|
||||
await friendicaLogout(biberPage, expectedFriendicaBaseUrl);
|
||||
await adminPage.goto(`${expectedFriendicaBaseUrl}/network`, { waitUntil: "domcontentloaded" });
|
||||
await expect(adminPage.locator("a[href*='/logout']").first()).toBeAttached({ timeout: 10_000 });
|
||||
|
||||
await friendicaLogout(adminPage, expectedFriendicaBaseUrl);
|
||||
} finally {
|
||||
await biberContext.close().catch(() => {});
|
||||
await adminContext.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Persona scenarios.
|
||||
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
|
||||
// so every role's persona flow stays consistent.
|
||||
async function friendicaLogout(page) {
|
||||
const baseUrl = friendicaBaseUrl.replace(/\/$/, "");
|
||||
await page.goto(`${baseUrl}/logout`, { waitUntil: "commit" }).catch(() => {});
|
||||
}
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: dashboard → app → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
// Administrator native login — picks the variant-specific path at runtime.
|
||||
// Skips entirely when neither oauth2 nor ldap is enabled (v1 has no auth
|
||||
// providers wired, the role only serves the public landing).
|
||||
test("friendica: administrator native login and logout", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
|
||||
if (isServiceEnabled("oauth2")) {
|
||||
// v0: full double-login via oauth2-proxy + Keycloak round-trip.
|
||||
await loginViaOauth2ProxyAndFriendica(page, adminUsername, adminPassword);
|
||||
} else {
|
||||
// v2: direct /login form, no Keycloak step.
|
||||
await loginViaFriendicaDirect(page, adminUsername, adminPassword);
|
||||
}
|
||||
|
||||
await friendicaLogout(page);
|
||||
await page.goto(`${friendicaBaseUrl.replace(/\/$/, "")}/network`, { waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
await expect(page.locator("a[href*='/logout']")).not.toBeAttached({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("administrator: dashboard → prometheus → app → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page, {
|
||||
adminInteraction: async (interactivePage) => {
|
||||
// web-app-friendica admin-only interaction: open a management surface.
|
||||
const link = interactivePage
|
||||
.getByRole("link", { name: /^(admin|administration|site|users)$/i })
|
||||
.first();
|
||||
if (await link.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
await link.click().catch(() => {});
|
||||
await interactivePage.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {});
|
||||
await expect(interactivePage.locator("body")).toContainText(
|
||||
/admin|site|users|database|federation/i,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
// biber + administrator side-by-side: two isolated browser contexts log in
|
||||
// independently, biber logs out first, admin session must persist. Exercises
|
||||
// ldapauth's bridge for a non-admin LDAP user AND verifies session isolation
|
||||
// between contexts. Skips when LDAP is disabled.
|
||||
test("friendica: biber + administrator side by side in isolated contexts", async ({ browser }) => {
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
|
||||
const login = isServiceEnabled("oauth2") ? loginViaOauth2ProxyAndFriendica : loginViaFriendicaDirect;
|
||||
const biberContext = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const adminContext = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
try {
|
||||
const biberPage = await biberContext.newPage();
|
||||
await login(biberPage, biberUsername, biberPassword);
|
||||
|
||||
const adminPage = await adminContext.newPage();
|
||||
await login(adminPage, adminUsername, adminPassword);
|
||||
|
||||
// biber logs out first — admin session must stay alive.
|
||||
await friendicaLogout(biberPage);
|
||||
await adminPage.goto(`${friendicaBaseUrl.replace(/\/$/, "")}/network`, { waitUntil: "domcontentloaded" });
|
||||
await expect(adminPage.locator("a[href*='/logout']").first()).toBeAttached({ timeout: 10_000 });
|
||||
|
||||
await friendicaLogout(adminPage);
|
||||
} finally {
|
||||
await biberContext.close().catch(() => {});
|
||||
await adminContext.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,10 @@ friendica:
|
||||
version: latest
|
||||
min_storage: 10GB
|
||||
addons:
|
||||
keycloakpassword: {}
|
||||
# SSO is owned by the oauth2-proxy gate in front of the friendica vhost
|
||||
# (services.oauth2.enabled=keycloak below). `ldapauth` stays enabled
|
||||
# because friendica has no header-trusted autocreate mechanism — the
|
||||
# ldap bind is the only path that materialises a friendica.user row.
|
||||
ldapauth: {}
|
||||
ports:
|
||||
local:
|
||||
@@ -25,7 +28,7 @@ logout:
|
||||
enabled: "{{ 'web-svc-logout' in group_names }}"
|
||||
shared: "{{ 'web-svc-logout' in group_names }}"
|
||||
ldap:
|
||||
enabled: true # nocheck: dynamic-flag
|
||||
enabled: "{{ 'svc-db-openldap' in group_names }}"
|
||||
shared: "{{ 'svc-db-openldap' in group_names }}"
|
||||
oidc:
|
||||
enabled: "{{ 'web-app-keycloak' in group_names }}"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
- name: Integrate LDAP
|
||||
include_tasks: 02_ldap.yml
|
||||
when: lookup('config',application_id, 'services.ldap.enabled')
|
||||
when: FRIENDICA_LDAP_ENABLED | bool
|
||||
|
||||
# General
|
||||
- name: "flush handlers to ensure that '{{ FRIENDICA_CONTAINER }}' is up before executing container tasks"
|
||||
@@ -28,3 +28,7 @@
|
||||
|
||||
- name: Add Friendica Add Ons
|
||||
include_tasks: 04_addons.yml
|
||||
|
||||
- name: Bootstrap Friendica administrator user (only when no LDAP provider is wired to provision them)
|
||||
include_tasks: 06_user_bootstrap.yml
|
||||
when: not (FRIENDICA_LDAP_ENABLED | bool)
|
||||
@@ -8,3 +8,26 @@
|
||||
group: 33
|
||||
force: true
|
||||
notify: compose up
|
||||
|
||||
# Upstream `ldap_createaccount` returns the raw `User::create()` result
|
||||
# (`['user' => UserRow, 'password' => '']`), but the `authenticate` hook
|
||||
# only sets `authenticated = 1` when `$user['uid']` is non-empty — which
|
||||
# is never true for the autocreate path. The fix unwraps `$user['user']`
|
||||
# so the freshly-created user's uid is visible to the hook. Without it
|
||||
# every first-login LDAP user (e.g. biber) silently fails to start a
|
||||
# session despite the row being inserted; the next request still serves
|
||||
# the login form. See web-app-friendica/TODO.md for the SAML migration
|
||||
# that would remove this whole code path.
|
||||
- name: "Patch upstream friendica-addons/ldapauth autocreate return shape"
|
||||
script: >-
|
||||
patch_ldapauth_autocreate.sh
|
||||
"{{ BIN_COMPOSE }} exec -T --user root {{ FRIENDICA_SERVICE }}"
|
||||
"{{ FRIENDICA_APPLICATION_DIR }}/addon/ldapauth/ldapauth.php"
|
||||
args:
|
||||
chdir: "{{ lookup('container', application_id, 'directories.instance') }}"
|
||||
executable: /bin/bash
|
||||
register: friendica_ldapauth_patch
|
||||
changed_when: "'PATCHED' in friendica_ldapauth_patch.stdout"
|
||||
retries: 30
|
||||
delay: 2
|
||||
until: friendica_ldapauth_patch.rc == 0
|
||||
@@ -10,9 +10,7 @@
|
||||
+ [{
|
||||
'name': item.key,
|
||||
'enabled': (
|
||||
FRIENDICA_OIDC_ENABLED
|
||||
if item.key == 'keycloakpassword'
|
||||
else FRIENDICA_LDAP_ENABLED
|
||||
FRIENDICA_LDAP_ENABLED
|
||||
if item.key == 'ldapauth'
|
||||
else (item.value.enabled if item.value is mapping and 'enabled' in item.value else False)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# Pre-create the administrator user in friendica.user so ldapauth's
|
||||
# else-branch (User::getAuthenticationInfo → User::getById) handles the
|
||||
# login. The autocreate path inside ldap_createaccount is broken in
|
||||
# stock friendica + ldapauth as of the version pinned here — it never
|
||||
# materialises the row even when ldap_bind succeeds. Pre-creating side-
|
||||
# steps the broken branch; the auto-generated friendica.user password
|
||||
# is irrelevant because ldap_bind owns the auth check.
|
||||
- name: "Check whether Friendica administrator user already exists"
|
||||
command: >-
|
||||
{{ BIN_COMPOSE }} exec -T --user {{ FRIENDICA_USER }}
|
||||
--workdir {{ FRIENDICA_APPLICATION_DIR }}
|
||||
{{ FRIENDICA_SERVICE }}
|
||||
{{ FRIENDICA_APPLICATION_DIR }}/bin/console user search nick administrator
|
||||
args:
|
||||
chdir: "{{ lookup('container', application_id, 'directories.instance') }}"
|
||||
register: friendica_admin_search
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: "Pre-create Friendica administrator user"
|
||||
command: >-
|
||||
{{ BIN_COMPOSE }} exec -T --user {{ FRIENDICA_USER }}
|
||||
--workdir {{ FRIENDICA_APPLICATION_DIR }}
|
||||
{{ FRIENDICA_SERVICE }}
|
||||
{{ FRIENDICA_APPLICATION_DIR }}/bin/console user add
|
||||
"{{ lookup('users', 'administrator').firstname | default('Administrator') }}"
|
||||
administrator
|
||||
"{{ lookup('users', 'administrator').email }}"
|
||||
en
|
||||
args:
|
||||
chdir: "{{ lookup('container', application_id, 'directories.instance') }}"
|
||||
when: "'administrator' not in friendica_admin_search.stdout"
|
||||
register: friendica_admin_create
|
||||
changed_when: friendica_admin_create.rc == 0
|
||||
@@ -6,7 +6,7 @@
|
||||
volumes:
|
||||
- html:{{ FRIENDICA_APPLICATION_DIR }}
|
||||
- data:/var/www/data # I assume that this one is unnessecarry
|
||||
- {{ FRIENDICA_LDAP_CONFIG_HOST }}:{{ FRIENDICA_LDAP_CONFIG_DOCKER }}:ro
|
||||
- {{ FRIENDICA_LDAP_CONFIG_HOST }}:{{ FRIENDICA_LDAP_CONFIG_DOCKER }}:ro
|
||||
ports:
|
||||
- "{{ DOCKER_BIND_HOST }}:{{ lookup('config', application_id, 'services.friendica.ports.local.http') }}:80"
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
|
||||
BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }}
|
||||
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
|
||||
|
||||
# Friendica authenticates via local DB + LDAP (ldapauth addon), not OIDC.
|
||||
# The generic persona helpers drive Keycloak/OAuth2-proxy round-trips and
|
||||
# cannot reconcile with Friendica's own login form. Bespoke admin/biber
|
||||
# tests above own the actual login flows.
|
||||
# Persona flows require a header-trusted auto-login that stock Friendica does
|
||||
# NOT provide (Owa.php / OAuth.php only read HTTP_AUTHORIZATION for API auth).
|
||||
# Block them whenever LDAP is wired in: the bespoke admin / biber tests below
|
||||
# already exercise the canonical login paths via ldapauth + native /login.
|
||||
# In LDAP-disabled variants there is no auth provider at all, so the personas
|
||||
# also can't run — same block keyed off LDAP for symmetry.
|
||||
PERSONA_BIBER_BLOCKED=true
|
||||
PERSONA_ADMINISTRATOR_BLOCKED=true
|
||||
# The Playwright container image has no custom ENTRYPOINT so the infinito CA cert
|
||||
@@ -22,8 +24,8 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
|
||||
|
||||
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
|
||||
# nocheck: playwright-service-gate — persona drives the OIDC path; LDAP federation is exercised in the LDAP-only matrix variant
|
||||
LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }}
|
||||
OIDC_SERVICE_ENABLED={{ lookup('config', application_id, 'services.oidc.enabled') | string | lower }}
|
||||
LDAP_SERVICE_ENABLED={{ FRIENDICA_LDAP_ENABLED | string | lower }}
|
||||
OIDC_SERVICE_ENABLED={{ FRIENDICA_OIDC_ENABLED | string | lower }}
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
|
||||
Reference in new issue
Block a user