feat(services): refine split-horizon OIDC routing and harden nextcloud patch

- authentik: address the rewrite service by compose service name instead
  of a network alias on the public FQDN, which shadowed extra_hosts pins
  and broke OIDC discovery for c-ares-based (Node) resolvers
- homarr: add homarr_extra_hosts to pin the IdP FQDN to a LAN IP so OIDC
  discovery stays in-network while the issuer matches the browser-facing URL
- opnform: add opnform_oidc_sso_redirect_root to 302 the root URL to the
  SSO path (deep-links untouched, /login?bypass=1 break-glass); restart
  ingress via container restart so envsubst re-renders nginx.conf
- nextcloud: make the UserConfig sed workaround fail loud on upstream
  drift instead of silently skipping (nextcloud/server#59629)
- gitignore: exclude the local .ansible/ collection cache
This commit is contained in:
Simon Bärlocher 2026-06-02 13:44:08 +02:00
parent 3236ca332f
commit 3ace667b6c
No known key found for this signature in database
GPG key ID: 63DE20495932047A
12 changed files with 264 additions and 49 deletions

3
.gitignore vendored
View file

@ -3,3 +3,6 @@ __pycache__/
*.pyc
plugins/lookup/__pycache__/
# Local Ansible collection cache (galaxy/collection resolver)
/.ansible/

View file

@ -44,13 +44,14 @@ services:
condition: service_healthy
networks:
{{ authentik_backend_network }}: {}
# Network alias so traefik (which shares this network) can resolve
# the canonical FQDN to this container directly. The URL-based
# service below uses that to send upstream traffic with a fixed
# Host header equal to the canonical hostname.
{{ authentik_traefik_network }}:
aliases:
- {{ authentik_domains[0] }}
# No alias for the public FQDN here: that would shadow `/etc/hosts`
# pins (extra_hosts) in other containers sharing this network and
# break OIDC discovery for Node-based clients (c-ares-based
# resolvers consult Docker DNS before /etc/hosts). The URL-based
# service below addresses this container by its compose service
# name `server`, which Docker exposes as an alias on every network
# the container joins.
{{ authentik_traefik_network }}: {}
labels:
- traefik.enable=true
- traefik.docker.network={{ authentik_traefik_network }}
@ -68,14 +69,14 @@ services:
- traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }}
{% if authentik_host_rewrite_domains | length > 0 %}
# Server-to-server entry: a separate service points at this very
# container by the canonical FQDN (resolved via the network alias
# above) and disables passHostHeader so the upstream Host header
# becomes `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer
# URLs from X-Forwarded-Host (not Host), so we also pin that header
# via middleware. Together this keeps the iss claim aligned with
# the public hostname browsers see during login, even when the
# request itself arrived on an internal *.int.* FQDN.
- traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://{{ authentik_domains[0] }}:{{ authentik_port }}
# container by its compose service name `server` and disables
# passHostHeader so the upstream Host header becomes
# `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer URLs
# from X-Forwarded-Host (not Host), so we also pin that header via
# middleware. Together this keeps the iss claim aligned with the
# public hostname browsers see during login, even when the request
# itself arrived on an internal *.int.* FQDN.
- traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://server:{{ authentik_port }}
- traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.passhostheader=false
- traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }}
{% for d in authentik_host_rewrite_domains %}

View file

@ -49,6 +49,7 @@ See `defaults/main.yml` for the full list. Most useful overrides:
|---|---|---|
| `homarr_domain` | `homarr.local.test` | Traefik Host rule |
| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN |
| `homarr_extra_hosts` | `[]` | Container `/etc/hosts` overrides (`host:ip`) — pin IdP FQDN to LAN IP |
| `homarr_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL |
| `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both |
| `homarr_oidc_issuer` | empty | Identity provider issuer URL |

View file

@ -19,6 +19,10 @@ homarr_domain: "homarr.local.test"
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
# by the cert).
homarr_extra_domains: []
# Extra /etc/hosts entries inside the homarr container (format "host:ip").
# Used to pin the IdP's public FQDN to a LAN IP so OIDC discovery stays
# in-network while the issuer URL matches what browsers see.
homarr_extra_hosts: []
homarr_image: "ghcr.io/homarr-labs/homarr:latest"
homarr_port: 7575
homarr_use_docker: false

View file

@ -26,6 +26,12 @@ services:
AUTH_OIDC_AUTO_LOGIN: "{{ homarr_oidc_auto_login | default('false') }}"
networks:
- {{ homarr_traefik_network }}
{% if homarr_extra_hosts | default([]) | length > 0 %}
extra_hosts:
{% for h in homarr_extra_hosts %}
- "{{ h }}"
{% endfor %}
{% endif %}
labels:
- traefik.enable=true
- traefik.docker.network={{ homarr_traefik_network }}

View file

@ -66,13 +66,32 @@
- name: Check UserConfig.php patch status per container
ansible.builtin.shell:
# rc 0 -> already patched; rc 1 -> still the unpatched original; rc 2 ->
# neither marker present (upstream drift -> the guard task below fails loud).
cmd: >-
docker exec {{ item }} grep -q "strtolower((string)" /var/www/html/lib/private/Config/UserConfig.php
docker exec {{ item }} sh -c '
grep -q "strtolower((string)\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 0;
grep -q "strtolower(\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 1;
exit 2'
loop: "{{ _nextcloud_php_containers.stdout_lines }}"
register: _nextcloud_userconfig_check
changed_when: false
failed_when: false
- name: Fail if the UserConfig.php source drifted from the expected upstream line
ansible.builtin.fail:
msg: >-
Neither the patched nor the expected original strtolower($this->getTypedValue(...))
line was found in {{ item.item }}:/var/www/html/lib/private/Config/UserConfig.php.
The nextcloud/server#59629 workaround can no longer locate its target — the upstream
source likely changed. Re-verify whether the fix shipped (then drop this block) or
update the sed expression. Silently skipping would let the TypeError regress.
loop: "{{ _nextcloud_userconfig_check.results }}"
loop_control:
label: "{{ item.item }}"
when:
- item.rc | default(2) == 2
- name: Apply UserConfig::getValueBool string-cast workaround
ansible.builtin.shell:
cmd: >-
@ -83,7 +102,7 @@
loop_control:
label: "{{ item.item }}"
when:
- item.rc | default(1) != 0
- item.rc | default(2) == 1
- name: Wait for Nextcloud to be ready
ansible.builtin.shell:

View file

@ -167,10 +167,16 @@ calls `/api/auth/{slug}/redirect` (no domain check) and forwards the
browser to the IdP authorize URL.
```yaml
opnform_oidc_sso_entrypoint: true # default false
opnform_oidc_sso_path: "/sso" # link users to https://<domain>/sso
opnform_oidc_sso_entrypoint: true # default false
opnform_oidc_sso_path: "/sso" # link users to https://<domain>/sso
opnform_oidc_sso_redirect_root: true # default false — root URL 302s to <sso_path>
```
With `opnform_oidc_sso_redirect_root` enabled both the bare hostname
and `/login` jump straight to the IdP. Public form deep-links
(`/forms/<slug>`, `/admin/...`) are not touched. The email form remains
reachable as a break-glass path via `/login?bypass=1`.
## Networking / split-horizon
```yaml

View file

@ -130,6 +130,13 @@ opnform_oidc_group_role_mappings: []
opnform_oidc_sso_entrypoint: false
opnform_oidc_sso_path: "/sso"
# When true, the ingress 302-redirects the root URL (exact-match on `/`)
# to opnform_oidc_sso_path so visiting https://<domain>/ jumps straight
# to the IdP login without showing OpnForm's email form. Public form
# deep-links (`/forms/<slug>`, `/login`, etc.) are untouched.
# Requires opnform_oidc_sso_entrypoint=true.
opnform_oidc_sso_redirect_root: false
# Traefik configuration
opnform_traefik_network: "proxy"
opnform_use_ssl: true

View file

@ -6,3 +6,13 @@
community.docker.docker_compose_v2:
project_src: "{{ opnform_docker_compose_dir }}"
state: restarted
# nginx.conf is bind-mounted into the ingress container and rendered to
# /etc/nginx/conf.d/default.conf by the envsubst entrypoint on container
# start. Plain `docker restart` re-runs that entrypoint, so the new
# template is picked up without bouncing db/redis/api/ui.
- name: restart opnform ingress
community.docker.docker_container:
name: opnform-ingress
state: started
restart: true

View file

@ -256,6 +256,16 @@ argument_specs:
- Path (on C(opnform_domain)) where the direct-SSO redirect page
is served when C(opnform_oidc_sso_entrypoint=true). Must start
with C(/) and not collide with OpnForm's own routes.
opnform_oidc_sso_redirect_root:
type: bool
default: false
description:
- When true, the nginx ingress 302-redirects the root URL
(exact-match on C(/)) to C(opnform_oidc_sso_path), so visiting
C(https://<domain>/) jumps straight to the IdP without
OpnForm's email login form. Public form deep-links
(C(/forms/<slug>), C(/login), C(/admin/...)) are untouched.
Requires C(opnform_oidc_sso_entrypoint=true).
opnform_traefik_network:
type: str

View file

@ -75,22 +75,97 @@
src: nginx.conf.j2
dest: "{{ opnform_docker_compose_dir }}/nginx.conf"
mode: '0644'
notify: restart opnform
notify: restart opnform ingress
# OIDC_FORCE_LOGIN disables OpnForm's password login — including the
# password-based admin/OIDC bootstrap this role performs below. So the
# first compose render always keeps force-login OFF; it is switched on
# only after the bootstrap completes (see step 7). This keeps a first
# deploy on a fresh host working even when opnform_oidc_force_login=true.
- name: Render compose with force-login disabled during bootstrap
# password-based admin/OIDC bootstrap this role performs below. The
# bootstrap must therefore run with force-login OFF. To stay idempotent
# on re-runs (avoid recreating api containers on every apply), we only
# turn force-login OFF when the bootstrap is actually needed (first run
# on a fresh host, no OIDC connection yet). Once the connection exists
# we render the final force-login value straight away, so the compose
# file is byte-identical across re-runs.
- name: Probe whether OpnForm is already bootstrapped
block:
- name: Check if opnform-api container exists and is healthy
ansible.builtin.command:
cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api
register: _opnform_api_health_probe
changed_when: false
failed_when: false
- name: Attempt admin login (only when api is healthy)
ansible.builtin.uri:
url: "https://127.0.0.1/api/login"
method: POST
headers:
Host: "{{ opnform_domain }}"
body_format: json
body:
email: "{{ opnform_admin_email }}"
password: "{{ opnform_admin_password }}"
status_code: [200, 401, 422]
validate_certs: false
register: _opnform_probe_login
no_log: true
when:
- _opnform_api_health_probe.rc == 0
- _opnform_api_health_probe.stdout == "healthy"
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- name: Probe for existing OIDC connection
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces"
method: GET
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ _opnform_probe_login.json.token }}"
status_code: 200
validate_certs: false
register: _opnform_probe_workspaces
no_log: true
when:
- opnform_oidc_enabled | bool
- _opnform_probe_login is defined
- _opnform_probe_login.status | default(0) == 200
- name: Probe OIDC connections on default workspace
ansible.builtin.uri:
url: "https://127.0.0.1/api/open/workspaces/{{ _opnform_probe_workspaces.json[0].id }}/oidc-connections"
method: GET
headers:
Host: "{{ opnform_domain }}"
Authorization: "Bearer {{ _opnform_probe_login.json.token }}"
status_code: 200
validate_certs: false
register: _opnform_probe_oidc
no_log: true
when:
- opnform_oidc_enabled | bool
- _opnform_probe_workspaces is defined
- _opnform_probe_workspaces.json | default([]) | length > 0
- name: Decide whether force-login can render in its final state
ansible.builtin.set_fact:
_opnform_force_login_effective: false
# True when force-login is desired AND admin+OIDC bootstrap has
# already completed (admin user exists with the configured password,
# OIDC connection is present). On a fresh host both checks fail and
# we fall back to false so the bootstrap below can run.
_opnform_force_login_effective: >-
{{
(opnform_oidc_enabled | bool)
and (opnform_oidc_force_login | bool)
and (_opnform_probe_login.status | default(0) == 200)
and ((_opnform_probe_oidc.json | default([])) | length > 0)
}}
- name: Deploy docker-compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
mode: '0644'
register: _opnform_compose_rendered
notify: restart opnform
# =====================================================================
@ -123,6 +198,12 @@
# Skips the self-hosted setup page by registering the first user via
# OpnForm's /api/register endpoint. Idempotent: a successful login
# attempt with the same credentials means the user already exists.
#
# Skipped entirely when force-login already rendered in its final state
# (probe in step 2 confirmed admin + connection exist). Re-running the
# /api/login probe on a force-login-enabled api would 401 and 422, so
# avoid the noise — and avoid spurious "changed" status from a register
# call that won't help anyway.
- name: Check if OpnForm admin user already exists
ansible.builtin.uri:
@ -140,6 +221,7 @@
when:
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- not (_opnform_force_login_effective | bool)
- name: Create OpnForm admin user via /api/register
ansible.builtin.uri:
@ -160,7 +242,8 @@
when:
- opnform_admin_email | length > 0
- opnform_admin_password | length > 0
- opnform_admin_login.status != 200
- not (_opnform_force_login_effective | bool)
- opnform_admin_login.status | default(0) != 200
# =====================================================================
# 6. OIDC IDENTITY CONNECTION (optional)
@ -171,6 +254,13 @@
# existing one to the desired state. PATCHing (rather than skipping when
# one exists) keeps inventory changes — e.g. a corrected issuer — applied
# on re-runs instead of leaving stale values in the DB forever.
#
# Skipped on re-applies when force-login is already enabled — the API
# password login required for these calls is disabled, and the connection
# is known to exist (otherwise force-login wouldn't have rendered in its
# final state in step 2). To intentionally re-provision the connection
# from inventory changes on such a host: temporarily set
# opnform_oidc_force_login=false, re-apply, then set it back to true.
- name: Log in as admin to obtain OIDC API token
ansible.builtin.uri:
@ -186,7 +276,9 @@
validate_certs: false
register: opnform_oidc_token
no_log: true
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- name: Fetch admin's workspaces
ansible.builtin.uri:
@ -199,7 +291,9 @@
validate_certs: false
register: opnform_workspaces
no_log: true
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- name: Fetch existing OIDC connections for the default workspace
ansible.builtin.uri:
@ -212,7 +306,9 @@
validate_certs: false
register: opnform_existing_oidc
no_log: true
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- name: Resolve OIDC group-role mappings
ansible.builtin.set_fact:
@ -224,7 +320,9 @@
([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}]
if (opnform_oidc_admin_group | length > 0) else [])
}}
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
# Desired connection state shared by both the create (POST) and update
# (PATCH) calls below. client_secret is always sent: OpnForm's update
@ -244,7 +342,9 @@
require_state: true
group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}"
no_log: true
when: opnform_oidc_enabled | bool
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- name: Create OIDC identity connection
ansible.builtin.uri:
@ -260,6 +360,7 @@
no_log: true
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- opnform_existing_oidc.json | length == 0
# An OIDC connection already exists: PATCH it to the desired state so
@ -280,20 +381,26 @@
no_log: true
when:
- opnform_oidc_enabled | bool
- not (_opnform_force_login_effective | bool)
- opnform_existing_oidc.json | length > 0
# =====================================================================
# 7. ENABLE FORCE LOGIN (optional, must run last)
# 7. ENABLE FORCE LOGIN (first-run only)
# =====================================================================
# OIDC_FORCE_LOGIN disables password login — including the password-based
# admin/OIDC bootstrap above — so it is switched on only now, after the
# connection is provisioned. OpnForm itself only enforces force-login when
# an enabled OIDC connection exists, so the order matters: connection
# first, force-login second.
- name: Enable force login now that the OIDC connection exists
# On the very first apply, step 2 rendered the compose file with
# force-login disabled (so the bootstrap above could use the password
# login). Now that the OIDC connection exists, re-render the compose
# file with force-login in its final state and recreate the api
# containers once.
#
# On all subsequent applies the probe in step 2 already rendered the
# final value, the compose file is byte-identical here, and this block
# is a no-op (the template task reports "ok", no recreate).
- name: Enable force login (first run, after OIDC bootstrap)
when:
- opnform_oidc_enabled | bool
- opnform_oidc_force_login | bool
- not (_opnform_force_login_effective | bool)
block:
- name: Re-render compose with force-login enabled
ansible.builtin.set_fact:
@ -314,6 +421,13 @@
wait_timeout: 180
when: _opnform_force_login_compose is changed
- name: Restart ingress so nginx picks up the new api container IPs
community.docker.docker_container:
name: opnform-ingress
state: started
restart: true
when: _opnform_force_login_compose is changed
- name: Display deployment info
ansible.builtin.debug:
msg: |-
@ -335,8 +449,9 @@
Users with @{{ opnform_oidc_domain }} addresses will be
redirected to {{ opnform_oidc_issuer }} on login.
{% if opnform_oidc_sso_entrypoint %}
Direct-SSO entrypoint: {{ opnform_base_url }}{{ opnform_oidc_sso_path }}
(link users here to skip the email login form)
Login intercept active: {{ opnform_base_url }}/login forwards
directly to the IdP. Use {{ opnform_base_url }}/login?bypass=1
as a break-glass path for the email form when the IdP is down.
{% endif %}
{% else %}
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)

View file

@ -15,20 +15,53 @@ server {
index index.html index.htm index.php;
# Re-resolve upstream container hostnames via Docker's embedded DNS
# at request time. Without this, nginx caches the first resolution
# forever; if `api` or `ui` get recreated and pick up a new IP, every
# request 502s until the ingress itself is restarted.
resolver 127.0.0.11 valid=10s ipv6=off;
set $upstream_api api;
set $upstream_ui ui;
{% if opnform_oidc_enabled and opnform_oidc_sso_entrypoint %}
# Direct-SSO entrypoint: a tiny page that asks the API for the IdP
# authorize URL (no email/domain check on this endpoint) and forwards
# the browser there. Link users here instead of /login to skip the
# email field entirely. Exact-match so it wins over the `/` prefix.
location = {{ opnform_oidc_sso_path }} {
# Root → /login. Public forms live under /forms/<slug>, so the bare
# hostname only serves the authenticated dashboard — sending it
# straight to /login (which then jumps to the IdP) saves an extra
# UI-side redirect for anyone who lands there.
location = / {
return 302 /login;
}
# /login intercept: serve a tiny HTML page that calls OpnForm's
# /api/auth/{slug}/redirect endpoint and forwards the browser to the
# IdP authorize URL — skipping the email-based login form entirely.
# Break-glass: /login?bypass=1 falls through to the UI's own login
# form so the email/password path stays reachable when the IdP is
# down. Bypass branches to a named location (`@login_bypass`) because
# `proxy_pass` inside an `if` block is invalid nginx config.
location = /login {
if ($arg_bypass = "1") {
error_page 418 = @login_bypass;
return 418;
}
default_type text/html;
return 200 '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Redirecting to sign-in…</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;color:#374151"><p id="m">Redirecting to sign-in…</p><script>fetch("/api/auth/{{ opnform_oidc_slug }}/redirect",{method:"POST",headers:{Accept:"application/json"}}).then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json()}).then(function(d){if(d&&d.redirect_url){window.location.replace(d.redirect_url)}else{throw new Error("no redirect_url")}}).catch(function(e){document.getElementById("m").textContent="Sign-in redirect failed: "+e.message+". Go to the login page instead.";var a=document.createElement("a");a.href="/login";a.textContent="Open login page";a.style.display="block";a.style.marginTop="1rem";document.body.appendChild(a)});</script></body></html>';
return 200 '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Redirecting to sign-in…</title></head><body style="font-family:sans-serif;text-align:center;padding:3rem;color:#374151"><p id="m">Redirecting to sign-in…</p><script>fetch("/api/auth/{{ opnform_oidc_slug }}/redirect",{method:"POST",headers:{Accept:"application/json"}}).then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json()}).then(function(d){if(d&&d.redirect_url){window.location.replace(d.redirect_url)}else{throw new Error("no redirect_url")}}).catch(function(e){document.getElementById("m").textContent="Sign-in redirect failed: "+e.message+". Open /login?bypass=1 to use the email form.";});</script></body></html>';
}
location @login_bypass {
proxy_http_version 1.1;
proxy_pass http://$upstream_ui:3000/login;
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 $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
{% endif %}
location / {
proxy_http_version 1.1;
proxy_pass http://ui:3000;
proxy_pass http://$upstream_ui:3000;
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 $scheme;
@ -45,7 +78,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass api:9000;
fastcgi_pass $upstream_api:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php;