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

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)