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
|
||||
|
||||
plugins/lookup/__pycache__/
|
||||
|
||||
# Local Ansible collection cache (galaxy/collection resolver)
|
||||
/.ansible/
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue