diff --git a/roles/keycloak/defaults/main.yml b/roles/keycloak/defaults/main.yml index 66d0a72..c242ea5 100644 --- a/roles/keycloak/defaults/main.yml +++ b/roles/keycloak/defaults/main.yml @@ -33,3 +33,66 @@ keycloak_use_ssl: true keycloak_log_level: "INFO" keycloak_proxy_mode: "edge" keycloak_gzip_enabled: false # Disable GZIP encoding to avoid MIME type issues + +# Provisioning configuration +keycloak_provisioning_enabled: false + +# Realm configuration +keycloak_realm: "default" +keycloak_realm_display_name: "Default Realm" + +# Auth URL for API access (used by provisioning tasks) +keycloak_auth_url: "{{ 'https' if keycloak_use_ssl else 'http' }}://{{ keycloak_domain }}" + +# Groups to provision +keycloak_groups: [] +# - name: admins +# - name: users + +# Local users to provision +keycloak_local_users: [] +# - username: admin +# first_name: "Admin" +# last_name: "User" +# email: "admin@example.com" +# password: "changeme" +# groups: +# - name: admins + +# OIDC clients to provision +keycloak_oidc_clients: [] +# - client_id: nextcloud +# name: "Nextcloud" +# client_secret: "changeme" +# redirect_uris: +# - "https://nextcloud.example.com/apps/user_oidc/code" +# default_client_scopes: +# - openid +# - email +# - profile + +# Identity providers (e.g., Entra ID, Google) +keycloak_identity_providers: [] +# - alias: entra-id +# display_name: "Login with Microsoft" +# provider_id: oidc +# config: +# clientId: "{{ entra_client_id }}" +# clientSecret: "{{ entra_client_secret }}" +# authorizationUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +# tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token" +# defaultScope: "openid profile email" + +# Resources to remove from Keycloak (cleanup) +# Add names/aliases here when removing from the lists above +keycloak_removed_users: [] +# - olduser + +keycloak_removed_groups: [] +# - oldgroup + +keycloak_removed_clients: [] +# - old-client + +keycloak_removed_identity_providers: [] +# - old-idp diff --git a/roles/keycloak/tasks/main.yml b/roles/keycloak/tasks/main.yml index 05db2ef..f8a0f1e 100644 --- a/roles/keycloak/tasks/main.yml +++ b/roles/keycloak/tasks/main.yml @@ -30,3 +30,25 @@ community.docker.docker_compose_v2: project_src: "{{ keycloak_docker_compose_dir }}" state: present + +- name: Wait for Keycloak health endpoint + uri: + url: "{{ keycloak_auth_url }}/health/ready" + method: GET + status_code: 200 + validate_certs: false + register: keycloak_health + until: keycloak_health.status == 200 + retries: 30 + delay: 10 + delegate_to: localhost + become: false + when: keycloak_provisioning_enabled | bool + +- name: Run Keycloak provisioning + ansible.builtin.include_tasks: provisioning.yml + args: + apply: + become: false + delegate_to: localhost + when: keycloak_provisioning_enabled | bool diff --git a/roles/keycloak/tasks/provisioning.yml b/roles/keycloak/tasks/provisioning.yml new file mode 100644 index 0000000..03ad6df --- /dev/null +++ b/roles/keycloak/tasks/provisioning.yml @@ -0,0 +1,156 @@ +#SPDX-License-Identifier: MIT-0 +--- +# Keycloak provisioning tasks +# Create realm (if not master) +- name: Create Keycloak realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + display_name: "{{ keycloak_realm_display_name }}" + enabled: true + state: present + validate_certs: false + no_log: true + when: keycloak_realm != "master" + +# Cleanup: Remove deleted identity providers +- name: Remove deleted identity providers + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + alias: "{{ item }}" + state: absent + validate_certs: false + loop: "{{ keycloak_removed_identity_providers }}" + no_log: true + +# Cleanup: Remove deleted clients +- name: Remove deleted clients + community.general.keycloak_client: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + client_id: "{{ item }}" + state: absent + validate_certs: false + loop: "{{ keycloak_removed_clients }}" + no_log: true + +# Cleanup: Remove deleted users +- name: Remove deleted users + community.general.keycloak_user: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + username: "{{ item }}" + state: absent + validate_certs: false + loop: "{{ keycloak_removed_users }}" + no_log: true + +# Cleanup: Remove deleted groups +- name: Remove deleted groups + community.general.keycloak_group: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + name: "{{ item }}" + state: absent + validate_certs: false + loop: "{{ keycloak_removed_groups }}" + no_log: true + +# Create groups +- name: Create groups + community.general.keycloak_group: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + name: "{{ item.name }}" + state: present + validate_certs: false + loop: "{{ keycloak_groups }}" + no_log: true + +# Create local users +- name: Create local users + community.general.keycloak_user: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + username: "{{ item.username }}" + first_name: "{{ item.first_name | default(omit) }}" + last_name: "{{ item.last_name | default(omit) }}" + email: "{{ item.email | default(omit) }}" + enabled: "{{ item.enabled | default(true) }}" + email_verified: "{{ item.email_verified | default(true) }}" + credentials: + - type: password + value: "{{ item.password }}" + temporary: false + groups: "{{ item.groups | default([]) }}" + state: present + validate_certs: false + loop: "{{ keycloak_local_users }}" + no_log: true + +# Create OIDC clients +- name: Create OIDC clients + community.general.keycloak_client: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + client_id: "{{ item.client_id }}" + name: "{{ item.name | default(item.client_id) }}" + enabled: true + client_authenticator_type: client-secret + secret: "{{ item.client_secret }}" + redirect_uris: "{{ item.redirect_uris | default([]) }}" + web_origins: "{{ item.web_origins | default(['+']) }}" + standard_flow_enabled: true + implicit_flow_enabled: false + direct_access_grants_enabled: "{{ item.direct_access_grants_enabled | default(false) }}" + protocol: openid-connect + default_client_scopes: "{{ item.default_client_scopes | default(['openid', 'email', 'profile']) }}" + state: present + validate_certs: false + loop: "{{ keycloak_oidc_clients }}" + no_log: true + +# Create identity providers +- name: Create identity providers + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ keycloak_auth_url }}" + auth_realm: master + auth_username: "{{ keycloak_admin_user }}" + auth_password: "{{ keycloak_admin_password }}" + realm: "{{ keycloak_realm }}" + alias: "{{ item.alias }}" + display_name: "{{ item.display_name | default(item.alias) }}" + provider_id: "{{ item.provider_id }}" + enabled: "{{ item.enabled | default(true) }}" + trust_email: "{{ item.trust_email | default(true) }}" + first_broker_login_flow_alias: "{{ item.first_broker_login_flow_alias | default('first broker login') }}" + config: "{{ item.config }}" + state: present + validate_certs: false + loop: "{{ keycloak_identity_providers }}" + no_log: true \ No newline at end of file diff --git a/roles/keycloak/templates/docker-compose.yml.j2 b/roles/keycloak/templates/docker-compose.yml.j2 index a91f746..2708f37 100644 --- a/roles/keycloak/templates/docker-compose.yml.j2 +++ b/roles/keycloak/templates/docker-compose.yml.j2 @@ -32,6 +32,7 @@ services: KC_SPI_RESOURCE_ENCODING_GZIP_CACHE_DIR: /opt/keycloak/data/gzip-cache KC_PROXY: {{ keycloak_proxy_mode }} KC_HOSTNAME: {{ keycloak_domain }} + KC_HEALTH_ENABLED: "true" depends_on: - postgres volumes: diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index 2e5a61e..1aa4ea3 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -14,6 +14,7 @@ nextcloud_image: "nextcloud:fpm" nextcloud_redis_image: "redis:latest" nextcloud_port: 80 nextcloud_extra_hosts: [] +nextcloud_allow_local_remote_servers: false # Set to true to allow requests to local network (dev only) nextcloud_postgres_image: "postgres:15" nextcloud_postgres_db: nextcloud @@ -55,4 +56,26 @@ nextcloud_apps_to_install: - spreed - user_ldap - user_oidc - - whiteboard \ No newline at end of file + - whiteboard + +# OIDC provider configuration +nextcloud_oidc_allow_selfsigned: false # Set to true to disable SSL verification for OIDC providers (dev only) +nextcloud_oidc_providers: [] +# - identifier: keycloak +# display_name: "Login with Keycloak" +# client_id: "nextcloud" +# client_secret: "changeme" +# discovery_url: "https://keycloak.example.com/realms/default/.well-known/openid-configuration" +# scope: "openid email profile" +# unique_uid: true +# check_bearer: false +# send_id_token_hint: true +# mapping: +# uid: preferred_username +# display_name: name +# email: email +# groups: groups + +# OIDC providers to remove +nextcloud_oidc_providers_removed: [] +# - old-provider \ No newline at end of file diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index f15103c..1d1a565 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -55,9 +55,21 @@ - (nextcloud_ready.stdout | from_json).installed == true changed_when: false +- name: Deploy local network config file + ansible.builtin.template: + src: local-network.config.php.j2 + dest: "{{ nextcloud_docker_volume_dir }}/nextcloud/config/local-network.config.php" + owner: www-data + group: www-data + mode: '0640' + - name: Install nextcloud plugins ansible.builtin.include_tasks: plugins.yml - name: Configure nextcloud collabora ansible.builtin.include_tasks: collabora.yml when: nextcloud_enable_collabora + +- name: Configure OIDC providers + ansible.builtin.include_tasks: oidc.yml + when: nextcloud_oidc_providers | length > 0 or nextcloud_oidc_providers_removed | length > 0 diff --git a/roles/nextcloud/tasks/oidc.yml b/roles/nextcloud/tasks/oidc.yml new file mode 100644 index 0000000..5a8d8f5 --- /dev/null +++ b/roles/nextcloud/tasks/oidc.yml @@ -0,0 +1,53 @@ +#SPDX-License-Identifier: MIT-0 +--- +# OIDC provider configuration for Nextcloud user_oidc app + +- name: Deploy OIDC config file + ansible.builtin.template: + src: oidc.config.php.j2 + dest: "{{ nextcloud_docker_volume_dir }}/nextcloud/config/oidc.config.php" + owner: www-data + group: www-data + mode: '0640' + +- name: Remove deleted OIDC providers + community.docker.docker_container_exec: + container: "{{ nextcloud_service_name }}-nextcloud-1" + command: php /var/www/html/occ user_oidc:provider:delete "{{ item }}" --force + loop: "{{ nextcloud_oidc_providers_removed }}" + register: oidc_delete_result + changed_when: "'deleted' in (oidc_delete_result.stdout | default('') | lower)" + failed_when: + - oidc_delete_result.rc != 0 + - "'not found' not in (oidc_delete_result.stderr | default('') | lower)" + - "'does not exist' not in (oidc_delete_result.stderr | default('') | lower)" + +- name: Create or update OIDC providers + vars: + _mapping: "{{ item.mapping | default({}) }}" + _base_args: + - php + - /var/www/html/occ + - user_oidc:provider + - "{{ item.identifier }}" + - "--clientid={{ item.client_id }}" + - "--clientsecret={{ item.client_secret }}" + - "--discoveryuri={{ item.discovery_url }}" + - "--unique-uid={{ '1' if item.unique_uid | default(true) else '0' }}" + - "--check-bearer={{ '1' if item.check_bearer | default(false) else '0' }}" + - "--send-id-token-hint={{ '1' if item.send_id_token_hint | default(true) else '0' }}" + _optional_args: "{{ + ((['--scope=' ~ item.scope]) if item.scope is defined else []) + + ((['--group-provisioning=1']) if item.group_provisioning | default(false) else []) + + ((['--mapping-uid=' ~ _mapping.uid]) if _mapping.uid is defined else []) + + ((['--mapping-display-name=' ~ _mapping.display_name]) if _mapping.display_name is defined else []) + + ((['--mapping-email=' ~ _mapping.email]) if _mapping.email is defined else []) + + ((['--mapping-groups=' ~ _mapping.groups]) if _mapping.groups is defined else []) + }}" + community.docker.docker_container_exec: + container: "{{ nextcloud_service_name }}-nextcloud-1" + argv: "{{ _base_args + _optional_args }}" + loop: "{{ nextcloud_oidc_providers }}" + register: oidc_create_result + changed_when: "'created' in (oidc_create_result.stdout | default('') | lower) or 'updated' in (oidc_create_result.stdout | default('') | lower)" + no_log: true \ No newline at end of file diff --git a/roles/nextcloud/templates/local-network.config.php.j2 b/roles/nextcloud/templates/local-network.config.php.j2 new file mode 100644 index 0000000..49f5b06 --- /dev/null +++ b/roles/nextcloud/templates/local-network.config.php.j2 @@ -0,0 +1,4 @@ + {{ nextcloud_allow_local_remote_servers | lower }}, +); \ No newline at end of file diff --git a/roles/nextcloud/templates/oidc.config.php.j2 b/roles/nextcloud/templates/oidc.config.php.j2 new file mode 100644 index 0000000..d09f638 --- /dev/null +++ b/roles/nextcloud/templates/oidc.config.php.j2 @@ -0,0 +1,6 @@ + array ( + 'httpclient.allowselfsigned' => {{ nextcloud_oidc_allow_selfsigned | lower }}, + ), +);