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:
parent
3236ca332f
commit
3ace667b6c
12 changed files with 264 additions and 49 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,3 +3,6 @@ __pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
plugins/lookup/__pycache__/
|
plugins/lookup/__pycache__/
|
||||||
|
|
||||||
|
# Local Ansible collection cache (galaxy/collection resolver)
|
||||||
|
/.ansible/
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,14 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
{{ authentik_backend_network }}: {}
|
{{ authentik_backend_network }}: {}
|
||||||
# Network alias so traefik (which shares this network) can resolve
|
# No alias for the public FQDN here: that would shadow `/etc/hosts`
|
||||||
# the canonical FQDN to this container directly. The URL-based
|
# pins (extra_hosts) in other containers sharing this network and
|
||||||
# service below uses that to send upstream traffic with a fixed
|
# break OIDC discovery for Node-based clients (c-ares-based
|
||||||
# Host header equal to the canonical hostname.
|
# resolvers consult Docker DNS before /etc/hosts). The URL-based
|
||||||
{{ authentik_traefik_network }}:
|
# service below addresses this container by its compose service
|
||||||
aliases:
|
# name `server`, which Docker exposes as an alias on every network
|
||||||
- {{ authentik_domains[0] }}
|
# the container joins.
|
||||||
|
{{ authentik_traefik_network }}: {}
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network={{ authentik_traefik_network }}
|
- traefik.docker.network={{ authentik_traefik_network }}
|
||||||
|
|
@ -68,14 +69,14 @@ services:
|
||||||
- traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }}
|
- traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }}
|
||||||
{% if authentik_host_rewrite_domains | length > 0 %}
|
{% if authentik_host_rewrite_domains | length > 0 %}
|
||||||
# Server-to-server entry: a separate service points at this very
|
# Server-to-server entry: a separate service points at this very
|
||||||
# container by the canonical FQDN (resolved via the network alias
|
# container by its compose service name `server` and disables
|
||||||
# above) and disables passHostHeader so the upstream Host header
|
# passHostHeader so the upstream Host header becomes
|
||||||
# becomes `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer
|
# `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer URLs
|
||||||
# URLs from X-Forwarded-Host (not Host), so we also pin that header
|
# from X-Forwarded-Host (not Host), so we also pin that header via
|
||||||
# via middleware. Together this keeps the iss claim aligned with
|
# middleware. Together this keeps the iss claim aligned with the
|
||||||
# the public hostname browsers see during login, even when the
|
# public hostname browsers see during login, even when the request
|
||||||
# request itself arrived on an internal *.int.* FQDN.
|
# itself arrived on an internal *.int.* FQDN.
|
||||||
- traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://{{ authentik_domains[0] }}:{{ authentik_port }}
|
- 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.services.{{ authentik_service_name }}-rewrite.loadbalancer.passhostheader=false
|
||||||
- traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }}
|
- traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }}
|
||||||
{% for d in authentik_host_rewrite_domains %}
|
{% for d in authentik_host_rewrite_domains %}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ See `defaults/main.yml` for the full list. Most useful overrides:
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `homarr_domain` | `homarr.local.test` | Traefik Host rule |
|
| `homarr_domain` | `homarr.local.test` | Traefik Host rule |
|
||||||
| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN |
|
| `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_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL |
|
||||||
| `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both |
|
| `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both |
|
||||||
| `homarr_oidc_issuer` | empty | Identity provider issuer URL |
|
| `homarr_oidc_issuer` | empty | Identity provider issuer URL |
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ homarr_domain: "homarr.local.test"
|
||||||
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
|
# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered
|
||||||
# by the cert).
|
# by the cert).
|
||||||
homarr_extra_domains: []
|
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_image: "ghcr.io/homarr-labs/homarr:latest"
|
||||||
homarr_port: 7575
|
homarr_port: 7575
|
||||||
homarr_use_docker: false
|
homarr_use_docker: false
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@ services:
|
||||||
AUTH_OIDC_AUTO_LOGIN: "{{ homarr_oidc_auto_login | default('false') }}"
|
AUTH_OIDC_AUTO_LOGIN: "{{ homarr_oidc_auto_login | default('false') }}"
|
||||||
networks:
|
networks:
|
||||||
- {{ homarr_traefik_network }}
|
- {{ homarr_traefik_network }}
|
||||||
|
{% if homarr_extra_hosts | default([]) | length > 0 %}
|
||||||
|
extra_hosts:
|
||||||
|
{% for h in homarr_extra_hosts %}
|
||||||
|
- "{{ h }}"
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network={{ homarr_traefik_network }}
|
- traefik.docker.network={{ homarr_traefik_network }}
|
||||||
|
|
|
||||||
|
|
@ -66,13 +66,32 @@
|
||||||
|
|
||||||
- name: Check UserConfig.php patch status per container
|
- name: Check UserConfig.php patch status per container
|
||||||
ansible.builtin.shell:
|
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: >-
|
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 }}"
|
loop: "{{ _nextcloud_php_containers.stdout_lines }}"
|
||||||
register: _nextcloud_userconfig_check
|
register: _nextcloud_userconfig_check
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_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
|
- name: Apply UserConfig::getValueBool string-cast workaround
|
||||||
ansible.builtin.shell:
|
ansible.builtin.shell:
|
||||||
cmd: >-
|
cmd: >-
|
||||||
|
|
@ -83,7 +102,7 @@
|
||||||
loop_control:
|
loop_control:
|
||||||
label: "{{ item.item }}"
|
label: "{{ item.item }}"
|
||||||
when:
|
when:
|
||||||
- item.rc | default(1) != 0
|
- item.rc | default(2) == 1
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
ansible.builtin.shell:
|
ansible.builtin.shell:
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,14 @@ browser to the IdP authorize URL.
|
||||||
```yaml
|
```yaml
|
||||||
opnform_oidc_sso_entrypoint: true # default false
|
opnform_oidc_sso_entrypoint: true # default false
|
||||||
opnform_oidc_sso_path: "/sso" # link users to https://<domain>/sso
|
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
|
## Networking / split-horizon
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,13 @@ opnform_oidc_group_role_mappings: []
|
||||||
opnform_oidc_sso_entrypoint: false
|
opnform_oidc_sso_entrypoint: false
|
||||||
opnform_oidc_sso_path: "/sso"
|
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
|
# Traefik configuration
|
||||||
opnform_traefik_network: "proxy"
|
opnform_traefik_network: "proxy"
|
||||||
opnform_use_ssl: true
|
opnform_use_ssl: true
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,13 @@
|
||||||
community.docker.docker_compose_v2:
|
community.docker.docker_compose_v2:
|
||||||
project_src: "{{ opnform_docker_compose_dir }}"
|
project_src: "{{ opnform_docker_compose_dir }}"
|
||||||
state: restarted
|
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
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,16 @@ argument_specs:
|
||||||
- Path (on C(opnform_domain)) where the direct-SSO redirect page
|
- Path (on C(opnform_domain)) where the direct-SSO redirect page
|
||||||
is served when C(opnform_oidc_sso_entrypoint=true). Must start
|
is served when C(opnform_oidc_sso_entrypoint=true). Must start
|
||||||
with C(/) and not collide with OpnForm's own routes.
|
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:
|
opnform_traefik_network:
|
||||||
type: str
|
type: str
|
||||||
|
|
|
||||||
|
|
@ -75,22 +75,97 @@
|
||||||
src: nginx.conf.j2
|
src: nginx.conf.j2
|
||||||
dest: "{{ opnform_docker_compose_dir }}/nginx.conf"
|
dest: "{{ opnform_docker_compose_dir }}/nginx.conf"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
notify: restart opnform
|
notify: restart opnform ingress
|
||||||
|
|
||||||
# OIDC_FORCE_LOGIN disables OpnForm's password login — including the
|
# OIDC_FORCE_LOGIN disables OpnForm's password login — including the
|
||||||
# password-based admin/OIDC bootstrap this role performs below. So the
|
# password-based admin/OIDC bootstrap this role performs below. The
|
||||||
# first compose render always keeps force-login OFF; it is switched on
|
# bootstrap must therefore run with force-login OFF. To stay idempotent
|
||||||
# only after the bootstrap completes (see step 7). This keeps a first
|
# on re-runs (avoid recreating api containers on every apply), we only
|
||||||
# deploy on a fresh host working even when opnform_oidc_force_login=true.
|
# turn force-login OFF when the bootstrap is actually needed (first run
|
||||||
- name: Render compose with force-login disabled during bootstrap
|
# 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:
|
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
|
- name: Deploy docker-compose file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: docker-compose.yml.j2
|
src: docker-compose.yml.j2
|
||||||
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
|
dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
|
register: _opnform_compose_rendered
|
||||||
notify: restart opnform
|
notify: restart opnform
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
@ -123,6 +198,12 @@
|
||||||
# Skips the self-hosted setup page by registering the first user via
|
# Skips the self-hosted setup page by registering the first user via
|
||||||
# OpnForm's /api/register endpoint. Idempotent: a successful login
|
# OpnForm's /api/register endpoint. Idempotent: a successful login
|
||||||
# attempt with the same credentials means the user already exists.
|
# 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
|
- name: Check if OpnForm admin user already exists
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
|
@ -140,6 +221,7 @@
|
||||||
when:
|
when:
|
||||||
- opnform_admin_email | length > 0
|
- opnform_admin_email | length > 0
|
||||||
- opnform_admin_password | length > 0
|
- opnform_admin_password | length > 0
|
||||||
|
- not (_opnform_force_login_effective | bool)
|
||||||
|
|
||||||
- name: Create OpnForm admin user via /api/register
|
- name: Create OpnForm admin user via /api/register
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
|
@ -160,7 +242,8 @@
|
||||||
when:
|
when:
|
||||||
- opnform_admin_email | length > 0
|
- opnform_admin_email | length > 0
|
||||||
- opnform_admin_password | 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)
|
# 6. OIDC IDENTITY CONNECTION (optional)
|
||||||
|
|
@ -171,6 +254,13 @@
|
||||||
# existing one to the desired state. PATCHing (rather than skipping when
|
# existing one to the desired state. PATCHing (rather than skipping when
|
||||||
# one exists) keeps inventory changes — e.g. a corrected issuer — applied
|
# one exists) keeps inventory changes — e.g. a corrected issuer — applied
|
||||||
# on re-runs instead of leaving stale values in the DB forever.
|
# 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
|
- name: Log in as admin to obtain OIDC API token
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
|
@ -186,7 +276,9 @@
|
||||||
validate_certs: false
|
validate_certs: false
|
||||||
register: opnform_oidc_token
|
register: opnform_oidc_token
|
||||||
no_log: true
|
no_log: true
|
||||||
when: opnform_oidc_enabled | bool
|
when:
|
||||||
|
- opnform_oidc_enabled | bool
|
||||||
|
- not (_opnform_force_login_effective | bool)
|
||||||
|
|
||||||
- name: Fetch admin's workspaces
|
- name: Fetch admin's workspaces
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
|
@ -199,7 +291,9 @@
|
||||||
validate_certs: false
|
validate_certs: false
|
||||||
register: opnform_workspaces
|
register: opnform_workspaces
|
||||||
no_log: true
|
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
|
- name: Fetch existing OIDC connections for the default workspace
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
|
@ -212,7 +306,9 @@
|
||||||
validate_certs: false
|
validate_certs: false
|
||||||
register: opnform_existing_oidc
|
register: opnform_existing_oidc
|
||||||
no_log: true
|
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
|
- name: Resolve OIDC group-role mappings
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
|
|
@ -224,7 +320,9 @@
|
||||||
([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}]
|
([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}]
|
||||||
if (opnform_oidc_admin_group | length > 0) else [])
|
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
|
# Desired connection state shared by both the create (POST) and update
|
||||||
# (PATCH) calls below. client_secret is always sent: OpnForm's update
|
# (PATCH) calls below. client_secret is always sent: OpnForm's update
|
||||||
|
|
@ -244,7 +342,9 @@
|
||||||
require_state: true
|
require_state: true
|
||||||
group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}"
|
group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}"
|
||||||
no_log: true
|
no_log: true
|
||||||
when: opnform_oidc_enabled | bool
|
when:
|
||||||
|
- opnform_oidc_enabled | bool
|
||||||
|
- not (_opnform_force_login_effective | bool)
|
||||||
|
|
||||||
- name: Create OIDC identity connection
|
- name: Create OIDC identity connection
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
|
@ -260,6 +360,7 @@
|
||||||
no_log: true
|
no_log: true
|
||||||
when:
|
when:
|
||||||
- opnform_oidc_enabled | bool
|
- opnform_oidc_enabled | bool
|
||||||
|
- not (_opnform_force_login_effective | bool)
|
||||||
- opnform_existing_oidc.json | length == 0
|
- opnform_existing_oidc.json | length == 0
|
||||||
|
|
||||||
# An OIDC connection already exists: PATCH it to the desired state so
|
# An OIDC connection already exists: PATCH it to the desired state so
|
||||||
|
|
@ -280,20 +381,26 @@
|
||||||
no_log: true
|
no_log: true
|
||||||
when:
|
when:
|
||||||
- opnform_oidc_enabled | bool
|
- opnform_oidc_enabled | bool
|
||||||
|
- not (_opnform_force_login_effective | bool)
|
||||||
- opnform_existing_oidc.json | length > 0
|
- 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
|
# On the very first apply, step 2 rendered the compose file with
|
||||||
# admin/OIDC bootstrap above — so it is switched on only now, after the
|
# force-login disabled (so the bootstrap above could use the password
|
||||||
# connection is provisioned. OpnForm itself only enforces force-login when
|
# login). Now that the OIDC connection exists, re-render the compose
|
||||||
# an enabled OIDC connection exists, so the order matters: connection
|
# file with force-login in its final state and recreate the api
|
||||||
# first, force-login second.
|
# containers once.
|
||||||
- name: Enable force login now that the OIDC connection exists
|
#
|
||||||
|
# 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:
|
when:
|
||||||
- opnform_oidc_enabled | bool
|
- opnform_oidc_enabled | bool
|
||||||
- opnform_oidc_force_login | bool
|
- opnform_oidc_force_login | bool
|
||||||
|
- not (_opnform_force_login_effective | bool)
|
||||||
block:
|
block:
|
||||||
- name: Re-render compose with force-login enabled
|
- name: Re-render compose with force-login enabled
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
|
|
@ -314,6 +421,13 @@
|
||||||
wait_timeout: 180
|
wait_timeout: 180
|
||||||
when: _opnform_force_login_compose is changed
|
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
|
- name: Display deployment info
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: |-
|
msg: |-
|
||||||
|
|
@ -335,8 +449,9 @@
|
||||||
Users with @{{ opnform_oidc_domain }} addresses will be
|
Users with @{{ opnform_oidc_domain }} addresses will be
|
||||||
redirected to {{ opnform_oidc_issuer }} on login.
|
redirected to {{ opnform_oidc_issuer }} on login.
|
||||||
{% if opnform_oidc_sso_entrypoint %}
|
{% if opnform_oidc_sso_entrypoint %}
|
||||||
Direct-SSO entrypoint: {{ opnform_base_url }}{{ opnform_oidc_sso_path }}
|
Login intercept active: {{ opnform_base_url }}/login forwards
|
||||||
(link users here to skip the email login form)
|
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 %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)
|
OIDC: disabled (set opnform_oidc_enabled=true to auto-configure)
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,53 @@ server {
|
||||||
|
|
||||||
index index.html index.htm index.php;
|
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 %}
|
{% if opnform_oidc_enabled and opnform_oidc_sso_entrypoint %}
|
||||||
# Direct-SSO entrypoint: a tiny page that asks the API for the IdP
|
# Root → /login. Public forms live under /forms/<slug>, so the bare
|
||||||
# authorize URL (no email/domain check on this endpoint) and forwards
|
# hostname only serves the authenticated dashboard — sending it
|
||||||
# the browser there. Link users here instead of /login to skip the
|
# straight to /login (which then jumps to the IdP) saves an extra
|
||||||
# email field entirely. Exact-match so it wins over the `/` prefix.
|
# UI-side redirect for anyone who lands there.
|
||||||
location = {{ opnform_oidc_sso_path }} {
|
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;
|
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 %}
|
{% endif %}
|
||||||
location / {
|
location / {
|
||||||
proxy_http_version 1.1;
|
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-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
@ -45,7 +78,7 @@ server {
|
||||||
|
|
||||||
location ~ \.php$ {
|
location ~ \.php$ {
|
||||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||||
fastcgi_pass api:9000;
|
fastcgi_pass $upstream_api:9000;
|
||||||
fastcgi_index index.php;
|
fastcgi_index index.php;
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php;
|
fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue