feat(services): multi-domain routing, split-horizon and OIDC hardening
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.
This commit is contained in:
parent
1dcff92240
commit
19864d79b2
17 changed files with 309 additions and 37 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}").
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
notify: restart homarr
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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://<domain>/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
|
||||
|
|
|
|||
|
|
@ -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: "<group name>", 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://<domain><sso_path> 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 '<!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>';
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_pass http://ui:3000;
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue