From 19864d79b205254daa788c959fd2b46c3d2c3c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 27 May 2026 16:18:29 +0200 Subject: [PATCH] feat(services): multi-domain routing, split-horizon and OIDC hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle of cross-role changes for the gymb services deployment: - Traefik routers: OR-combine opnform/homarr/bookstack Host rules with new *_extra_domains (internal *.int.* FQDNs for a DMZ reverseproxy), and emit tls.certresolver only when traefik_cert_mode == acme (drawio, homarr, opnform, send). - Split-horizon: bookstack_extra_hosts / opnform_extra_hosts add container /etc/hosts overrides so containers reach the IdP public FQDN over the LAN. - bookstack: assert the OIDC issuer resolves concretely (reject "//v2.0"), allowing non-Entra IdPs that override bookstack_oidc_issuer. - homarr: derive the bcrypt salt from the password digest so the admin hash is idempotent — no spurious template changes / container restarts. - opnform: PATCH an existing OIDC connection instead of skipping (applies corrected inventory on re-run); add OIDC_FORCE_LOGIN (enabled only after bootstrap) and an optional direct-SSO ingress entrypoint. Docs: READMEs and meta/argument_specs.yml updated for all new variables. --- roles/bookstack/README.md | 15 ++- roles/bookstack/defaults/main.yml | 8 ++ roles/bookstack/meta/argument_specs.yml | 18 ++++ roles/bookstack/tasks/main.yml | 8 +- .../bookstack/templates/docker-compose.yml.j2 | 8 +- roles/drawio/templates/docker-compose.yml.j2 | 3 + roles/homarr/README.md | 1 + roles/homarr/defaults/main.yml | 4 + roles/homarr/tasks/main.yml | 24 ++--- roles/homarr/templates/docker-compose.yml.j2 | 5 +- roles/opnform/README.md | 52 ++++++++- roles/opnform/defaults/main.yml | 26 +++++ roles/opnform/meta/argument_specs.yml | 45 ++++++++ roles/opnform/tasks/main.yml | 101 +++++++++++++++--- roles/opnform/templates/docker-compose.yml.j2 | 14 ++- roles/opnform/templates/nginx.conf.j2 | 11 ++ roles/send/templates/docker-compose.yml.j2 | 3 + 17 files changed, 309 insertions(+), 37 deletions(-) diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md index 25fb789..6dfd776 100644 --- a/roles/bookstack/README.md +++ b/roles/bookstack/README.md @@ -22,9 +22,14 @@ The role asserts these are set; the play fails fast if any is empty: | `bookstack_db_root_password` | MariaDB root password | | `bookstack_db_password` | MariaDB user password | | `bookstack_admin_password` | Initial local admin password | -| `bookstack_oidc_client_id` | Entra ID App Registration ID (if OIDC on) | -| `bookstack_oidc_client_secret` | Entra ID client secret (if OIDC on) | -| `bookstack_entra_tenant_id` | Entra tenant UUID (if OIDC on) | +| `bookstack_oidc_client_id` | OIDC client ID (if OIDC on) | +| `bookstack_oidc_client_secret` | OIDC client secret (if OIDC on) | + +When OIDC is on, the role also asserts that `bookstack_oidc_issuer` +resolves to a concrete URL. For Entra ID this means setting +`bookstack_entra_tenant_id` (the default issuer interpolates it; an unset +tenant leaves `//v2.0` and fails the assert). For other IdPs (Authentik, +Keycloak) set `bookstack_oidc_issuer` directly instead. Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit real secrets. @@ -34,6 +39,10 @@ real secrets. See `defaults/main.yml`. Frequently overridden: - `bookstack_domain`, `bookstack_base_url` +- `bookstack_extra_domains` (extra Host-rule hostnames, e.g. an internal + `*.int.*` FQDN for a DMZ reverseproxy) +- `bookstack_extra_hosts` (container `/etc/hosts` overrides for + split-horizon IdP access; entries as `host:ip`) - `bookstack_image`, `bookstack_db_image` (pin in production) - `bookstack_oidc_enabled` (set `false` to disable OIDC entirely) - `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP) diff --git a/roles/bookstack/defaults/main.yml b/roles/bookstack/defaults/main.yml index 3efbadb..ac464b8 100644 --- a/roles/bookstack/defaults/main.yml +++ b/roles/bookstack/defaults/main.yml @@ -16,6 +16,14 @@ bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup" # Service configuration bookstack_domain: "wiki.local.test" +# Additional hostnames the bookstack router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +bookstack_extra_domains: [] +# Container-level /etc/hosts overrides — useful in split-horizon setups +# where the BookStack container needs to reach an IdP's public FQDN +# (used in the OIDC `iss` claim) over the LAN rather than via the DMZ. +bookstack_extra_hosts: [] bookstack_base_url: "https://{{ bookstack_domain }}" # Images — pin via inventory in production diff --git a/roles/bookstack/meta/argument_specs.yml b/roles/bookstack/meta/argument_specs.yml index 8546cde..07f0c06 100644 --- a/roles/bookstack/meta/argument_specs.yml +++ b/roles/bookstack/meta/argument_specs.yml @@ -37,6 +37,24 @@ argument_specs: type: str default: wiki.local.test description: Hostname used in the Traefik Host rule. + bookstack_extra_domains: + type: list + elements: str + default: [] + description: + - Additional hostnames the Traefik router answers on, OR-combined + with C(bookstack_domain). Useful for an internal C(*.int.*) FQDN + so a DMZ reverseproxy can reach a backend hostname covered by the + cert. + bookstack_extra_hosts: + type: list + elements: str + default: [] + description: + - Container-level C(/etc/hosts) overrides (Compose C(extra_hosts) + entries, C("host:ip")). Useful in split-horizon setups where the + BookStack container must reach an IdP's public FQDN (used in the + OIDC C(iss) claim) over the LAN rather than via the DMZ. bookstack_base_url: type: str description: Defaults to C("https://{{ bookstack_domain }}"). diff --git a/roles/bookstack/tasks/main.yml b/roles/bookstack/tasks/main.yml index 1ea325b..73218d2 100644 --- a/roles/bookstack/tasks/main.yml +++ b/roles/bookstack/tasks/main.yml @@ -14,7 +14,13 @@ - bookstack_admin_password | length > 0 - (not bookstack_oidc_enabled) or (bookstack_oidc_client_id | length > 0) - (not bookstack_oidc_enabled) or (bookstack_oidc_client_secret | length > 0) - - (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0) + # Issuer URL must resolve to something concrete. The Entra default + # interpolates bookstack_entra_tenant_id; an unset tenant leaves + # "//v2.0" in the URL. Allow non-Entra IdPs (Authentik, Keycloak) + # that override bookstack_oidc_issuer directly. + - (not bookstack_oidc_enabled) or + (bookstack_oidc_issuer | length > 0 and + '//v2.0' not in bookstack_oidc_issuer) fail_msg: >- One or more required secrets are unset. Provide them via OpenBao lookup, Ansible Vault or --extra-vars. See README for the full list. diff --git a/roles/bookstack/templates/docker-compose.yml.j2 b/roles/bookstack/templates/docker-compose.yml.j2 index 863e316..3300826 100644 --- a/roles/bookstack/templates/docker-compose.yml.j2 +++ b/roles/bookstack/templates/docker-compose.yml.j2 @@ -45,13 +45,19 @@ services: networks: - {{ bookstack_traefik_network }} - internal +{% if bookstack_extra_hosts | length > 0 %} + extra_hosts: +{% for host in bookstack_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} depends_on: {{ bookstack_service_name }}-db: condition: service_healthy labels: - "traefik.enable=true" - "traefik.docker.network={{ bookstack_traefik_network }}" - - "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)" + - "traefik.http.routers.{{ bookstack_service_name }}.rule={% set _all_domains = [bookstack_domain] + (bookstack_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}" - "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure" - "traefik.http.routers.{{ bookstack_service_name }}.tls=true" - "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}" diff --git a/roles/drawio/templates/docker-compose.yml.j2 b/roles/drawio/templates/docker-compose.yml.j2 index 65eb396..a7e44b7 100644 --- a/roles/drawio/templates/docker-compose.yml.j2 +++ b/roles/drawio/templates/docker-compose.yml.j2 @@ -19,6 +19,9 @@ services: {% if drawio_use_ssl %} - traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure - traefik.http.routers.{{ drawio_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ drawio_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ drawio_service_name }}.entrypoints=web {% endif %} diff --git a/roles/homarr/README.md b/roles/homarr/README.md index 1e92cba..77d6447 100644 --- a/roles/homarr/README.md +++ b/roles/homarr/README.md @@ -46,6 +46,7 @@ See `defaults/main.yml` for the full list. Most useful overrides: | Variable | Default | Purpose | |---|---|---| | `homarr_domain` | `homarr.local.test` | Traefik Host rule | +| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN | | `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 | diff --git a/roles/homarr/defaults/main.yml b/roles/homarr/defaults/main.yml index f6ef75e..3d22ee7 100644 --- a/roles/homarr/defaults/main.yml +++ b/roles/homarr/defaults/main.yml @@ -15,6 +15,10 @@ homarr_db: "{{ homarr_appdata_dir }}/db/db.sqlite" # Service configuration homarr_domain: "homarr.local.test" +# Additional hostnames the homarr router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +homarr_extra_domains: [] homarr_image: "ghcr.io/homarr-labs/homarr:latest" homarr_port: 7575 homarr_use_docker: false diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index ffb0bb7..c7e4cea 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -112,19 +112,17 @@ # ===================================================================== - name: Generate bcrypt hash for admin password - ansible.builtin.shell: - cmd: python3 -c "import bcrypt, sys; print(bcrypt.hashpw(sys.stdin.read().encode(), bcrypt.gensalt(rounds=10)).decode())" - stdin: "{{ homarr_admin_password }}" - stdin_add_newline: false - delegate_to: localhost - become: false - register: bcrypt_result - changed_when: false - no_log: true - -- name: Set bcrypt hash fact ansible.builtin.set_fact: - homarr_bcrypt_hash: "{{ bcrypt_result.stdout }}" + # Deterministic salt derived from the password's SHA-256 digest so the + # hash stays stable across runs (idempotent — no spurious template + # changes / container restarts when the password is unchanged). The + # bcrypt salt alphabet is [./A-Za-z0-9]; the digest's hex chars are + # a strict subset, so we just take the first 22. + homarr_bcrypt_hash: >- + {{ homarr_admin_password + | password_hash('bcrypt', rounds=10, + salt=(homarr_admin_password + | hash('sha256'))[:22]) }} no_log: true # ===================================================================== @@ -161,4 +159,4 @@ register: seed_result changed_when: seed_result.rc == 0 when: admin_exists.stdout == "" - notify: restart homarr \ No newline at end of file + notify: restart homarr diff --git a/roles/homarr/templates/docker-compose.yml.j2 b/roles/homarr/templates/docker-compose.yml.j2 index 2d81063..5907763 100644 --- a/roles/homarr/templates/docker-compose.yml.j2 +++ b/roles/homarr/templates/docker-compose.yml.j2 @@ -29,10 +29,13 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ homarr_traefik_network }} - - traefik.http.routers.homarr.rule=Host(`{{ homarr_domain }}`) + - traefik.http.routers.homarr.rule={% set _all_domains = [homarr_domain] + (homarr_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if homarr_use_ssl %} - traefik.http.routers.homarr.entrypoints=websecure - traefik.http.routers.homarr.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.homarr.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.homarr.entrypoints=web {% endif %} diff --git a/roles/opnform/README.md b/roles/opnform/README.md index 2dfad2d..0722178 100644 --- a/roles/opnform/README.md +++ b/roles/opnform/README.md @@ -11,9 +11,10 @@ Docker Compose stack behind Traefik. - Integrates the ingress container with an existing Traefik proxy network - Waits for the API container to become healthy before returning -## What this role does NOT do (stage 1) +## What this role does NOT do -- Does not pre-configure OIDC / identity_connections — set up via Admin UI +- Does not migrate existing OpnForm databases — only bootstraps fresh + installs (admin registration + OIDC connection are idempotent) ## Architecture note: why two reverse proxies? @@ -91,11 +92,14 @@ Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit ## OIDC setup -Set `opnform_oidc_enabled: true` and the role creates an +Set `opnform_oidc_enabled: true` and the role provisions an IdentityConnection on the admin's default workspace via `POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a -single OIDC connection per workspace, so the task is idempotent (GETs -existing connections first and skips if any exist). +single OIDC connection per workspace, so the task is idempotent: it GETs +existing connections first, then either POSTs a new one or PATCHes the +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. **Prerequisite**: the admin bootstrap must be configured (`opnform_admin_email` + `opnform_admin_password`). The OIDC API @@ -138,6 +142,44 @@ opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin Valid roles: `owner`, `admin`, `editor`, `member`. +### Force OIDC-only login + +```yaml +opnform_oidc_force_login: true # default false +``` + +Sets `OIDC_FORCE_LOGIN=true` on the API: password login is disabled and +every user must authenticate via OIDC. The role keeps force-login **off** +during the first deploy (the admin/OIDC bootstrap is password-based) and +switches it on only after the OIDC connection is provisioned, recreating +the API containers. Ensure all real users have addresses under +`opnform_oidc_domain` before enabling — there is no password fallback. + +### Direct-SSO entrypoint + +OpnForm has no native way to skip the email login form and jump straight +to the IdP. When enabled, the ingress serves a tiny redirect page that +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:///sso +``` + +## Networking / split-horizon + +```yaml +opnform_extra_domains: [] # extra Host-rule hostnames (OR-combined) +opnform_extra_hosts: [] # API container /etc/hosts overrides ("host:ip") +``` + +`opnform_extra_domains` adds internal `*.int.*` FQDNs so a DMZ +reverseproxy can reach a backend hostname covered by the cert. +`opnform_extra_hosts` lets the API containers reach the IdP's public FQDN +(used in the OIDC `iss` claim) over the LAN when the DMZ has no NAT +loopback. + ## Example playbook ```yaml diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml index 0f61c3a..9a79b07 100644 --- a/roles/opnform/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -16,6 +16,15 @@ opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis" # Service configuration opnform_domain: "forms.local.test" +# Additional hostnames the opnform router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +opnform_extra_domains: [] +# Container-level /etc/hosts overrides for the API containers — needed in +# split-horizon setups where the OpnForm API must reach the IdP's public +# FQDN (used in the OIDC discovery/iss claim) over the LAN rather than +# hairpinning through a DMZ that has no NAT loopback to its own public IP. +opnform_extra_hosts: [] opnform_base_url: "https://forms.local.test" # Images @@ -92,6 +101,12 @@ opnform_oidc_slug: "oidc" # with @example.com emails are redirected to the IdP). Required when # opnform_oidc_enabled is true. opnform_oidc_domain: "" +# When true, sets OIDC_FORCE_LOGIN on the api: password-based login is +# disabled entirely and every user must authenticate via OIDC. Only +# rendered when opnform_oidc_enabled is also true. Make sure all real +# users have addresses under opnform_oidc_domain before enabling — there +# is no password fallback once this is on. +opnform_oidc_force_login: false opnform_oidc_scopes: - openid - profile @@ -104,6 +119,17 @@ opnform_oidc_admin_group: "opnform-admins" # var. Each item: {idp_group: "", role: "owner|admin|editor|member"} opnform_oidc_group_role_mappings: [] +# Direct-SSO entrypoint. OpnForm has no built-in way to skip the email +# login form and jump straight to the IdP (verified: config/oidc.php only +# exposes force_login; the login form always routes by email domain). When +# this is enabled the ingress serves a tiny page at opnform_oidc_sso_path +# that calls OpnForm's /api/auth/{slug}/redirect endpoint (which performs +# no domain check) and forwards the browser to the returned authorize URL +# — nonce/state included. Link users to https:// instead +# of /login. Requires opnform_oidc_enabled. +opnform_oidc_sso_entrypoint: false +opnform_oidc_sso_path: "/sso" + # Traefik configuration opnform_traefik_network: "proxy" opnform_use_ssl: true diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml index 9fbfc7a..5e1248d 100644 --- a/roles/opnform/meta/argument_specs.yml +++ b/roles/opnform/meta/argument_specs.yml @@ -38,6 +38,25 @@ argument_specs: type: str default: forms.local.test description: Hostname used in the traefik Host rule. + opnform_extra_domains: + type: list + elements: str + default: [] + description: + - Additional hostnames the Traefik router answers on, OR-combined + with C(opnform_domain). Useful for an internal C(*.int.*) FQDN so + a DMZ reverseproxy can reach a backend hostname covered by the + cert. + opnform_extra_hosts: + type: list + elements: str + default: [] + description: + - Container-level C(/etc/hosts) overrides for the API containers + (Compose C(extra_hosts) entries, C("host:ip")). Needed in + split-horizon setups where the OpnForm API must reach the IdP's + public FQDN (used in the OIDC discovery / C(iss) claim) over the + LAN rather than hairpinning through a DMZ with no NAT loopback. opnform_base_url: type: str default: https://forms.local.test @@ -184,6 +203,15 @@ argument_specs: description: - Email domain that triggers OIDC for matching users. Required when C(opnform_oidc_enabled=true). + opnform_oidc_force_login: + type: bool + default: false + description: + - "When true, sets C(OIDC_FORCE_LOGIN=true) on the api container: + password-based login is disabled and every user must authenticate + via OIDC. Only takes effect when C(opnform_oidc_enabled=true). + Ensure all real users have addresses under C(opnform_oidc_domain) + before enabling — there is no password fallback." opnform_oidc_scopes: type: list elements: str @@ -211,6 +239,23 @@ argument_specs: type: str required: true choices: [owner, admin, editor, member] + opnform_oidc_sso_entrypoint: + type: bool + default: false + description: + - When true (and C(opnform_oidc_enabled=true)) the nginx ingress + serves a small redirect page at C(opnform_oidc_sso_path) that + calls OpnForm's C(/api/auth/{slug}/redirect) endpoint and + forwards the browser to the returned IdP authorize URL. Lets + you link users straight to the IdP, skipping OpnForm's + email-based login form. OpnForm has no native option for this. + opnform_oidc_sso_path: + type: str + default: /sso + description: + - 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_traefik_network: type: str diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml index 68e093b..91901c5 100644 --- a/roles/opnform/tasks/main.yml +++ b/roles/opnform/tasks/main.yml @@ -76,6 +76,15 @@ mode: '0644' notify: restart opnform +# 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 + ansible.builtin.set_fact: + _opnform_force_login_effective: false + - name: Deploy docker-compose file ansible.builtin.template: src: docker-compose.yml.j2 @@ -155,9 +164,12 @@ # ===================================================================== # 6. OIDC IDENTITY CONNECTION (optional) # ===================================================================== -# Creates a single OIDC connection on the admin's default workspace. -# OpnForm enforces one OIDC connection per workspace, so this block is -# idempotent: we GET existing connections first and skip if any exists. +# Provisions a single OIDC connection on the admin's default workspace. +# OpnForm enforces one OIDC connection per workspace, so we GET the +# existing connections first and then either POST a new one or PATCH the +# 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. - name: Log in as admin to obtain OIDC API token ansible.builtin.uri: @@ -213,15 +225,12 @@ }} when: opnform_oidc_enabled | bool -- name: Create OIDC identity connection - ansible.builtin.uri: - url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" - method: POST - headers: - Host: "{{ opnform_domain }}" - Authorization: "Bearer {{ opnform_oidc_token.json.token }}" - body_format: json - body: +# Desired connection state shared by both the create (POST) and update +# (PATCH) calls below. client_secret is always sent: OpnForm's update +# endpoint only persists it when present, and on create it is required. +- name: Build desired OIDC connection body + ansible.builtin.set_fact: + _opnform_oidc_body: name: "{{ opnform_oidc_client_name }}" slug: "{{ opnform_oidc_slug }}" domain: "{{ opnform_oidc_domain }}" @@ -233,6 +242,18 @@ options: require_state: true group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" + no_log: true + when: opnform_oidc_enabled | bool + +- name: Create OIDC identity connection + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: POST + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + body_format: json + body: "{{ _opnform_oidc_body }}" status_code: [201] validate_certs: false no_log: true @@ -240,6 +261,58 @@ - opnform_oidc_enabled | bool - opnform_existing_oidc.json | length == 0 +# An OIDC connection already exists: PATCH it to the desired state so +# inventory changes (e.g. a corrected issuer) are applied. OpnForm allows +# exactly one connection per workspace, so the first entry is ours. +- name: Update existing OIDC identity connection + ansible.builtin.uri: + url: >- + https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections/{{ opnform_existing_oidc.json[0].id }} + method: PATCH + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + body_format: json + body: "{{ _opnform_oidc_body }}" + status_code: [200] + validate_certs: false + no_log: true + when: + - opnform_oidc_enabled | bool + - opnform_existing_oidc.json | length > 0 + +# ===================================================================== +# 7. ENABLE FORCE LOGIN (optional, must run last) +# ===================================================================== +# 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 + when: + - opnform_oidc_enabled | bool + - opnform_oidc_force_login | bool + block: + - name: Re-render compose with force-login enabled + ansible.builtin.set_fact: + _opnform_force_login_effective: true + + - name: Deploy docker-compose file with force-login enabled + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + register: _opnform_force_login_compose + + - name: Apply force-login by recreating the api containers + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: present + wait: true + wait_timeout: 180 + when: _opnform_force_login_compose is changed + - name: Display deployment info ansible.builtin.debug: msg: |- @@ -260,6 +333,10 @@ (slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }}) 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) + {% endif %} {% else %} OIDC: disabled (set opnform_oidc_enabled=true to auto-configure) {% endif %} diff --git a/roles/opnform/templates/docker-compose.yml.j2 b/roles/opnform/templates/docker-compose.yml.j2 index de88a33..6b5866c 100644 --- a/roles/opnform/templates/docker-compose.yml.j2 +++ b/roles/opnform/templates/docker-compose.yml.j2 @@ -6,6 +6,12 @@ services: image: {{ opnform_api_image }} container_name: opnform-api restart: unless-stopped +{% if opnform_extra_hosts | length > 0 %} + extra_hosts: +{% for host in opnform_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} volumes: - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw environment: &api-env @@ -14,6 +20,9 @@ services: APP_URL: "{{ opnform_base_url }}" APP_DEBUG: "false" SELF_HOSTED: "true" +{% if opnform_oidc_enabled and (_opnform_force_login_effective | default(false)) %} + OIDC_FORCE_LOGIN: "true" +{% endif %} LOG_CHANNEL: errorlog LOG_LEVEL: info @@ -173,10 +182,13 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ opnform_traefik_network }} - - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) + - traefik.http.routers.{{ opnform_service_name }}.rule={% set _all_domains = [opnform_domain] + (opnform_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if opnform_use_ssl %} - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure - traefik.http.routers.{{ opnform_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ opnform_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web {% endif %} diff --git a/roles/opnform/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 index fa3193b..6f62840 100644 --- a/roles/opnform/templates/nginx.conf.j2 +++ b/roles/opnform/templates/nginx.conf.j2 @@ -15,6 +15,17 @@ server { index index.html index.htm index.php; +{% 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 }} { + default_type text/html; + return 200 'Redirecting to sign-in…

Redirecting to sign-in…

'; + } + +{% endif %} location / { proxy_http_version 1.1; proxy_pass http://ui:3000; diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 index 69a43ab..28f1eaa 100644 --- a/roles/send/templates/docker-compose.yml.j2 +++ b/roles/send/templates/docker-compose.yml.j2 @@ -50,6 +50,9 @@ services: {% if send_use_ssl %} - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure - traefik.http.routers.{{ send_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ send_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ send_service_name }}.entrypoints=web {% endif %}