Auto Configure SAML
Feature Request
Why
Currently, SAML configuration is a manual process. Keycloak must come up to get the auto-generated saml connect config. Then, SonarQube must be re-installed to pick up the yaml values file change.
This make SSO testing difficult for developers and end-users using the the default baby-yoda realm.
Proposed Solution
Anchore provides a fully automated way to get the value from keycloak as the charts are installing. Then, using Anchore's API, the SAML config is set. See https://repo1.dso.mil/big-bang/product/packages/anchore-enterprise/-/blob/main/chart/templates/bigbang/sso/configure-sso.yaml
See if something similar can be done for SonarQube and implement a similar hook.
Designs
- Show closed items
Activity
-
Newest first Oldest first
-
Show all activity Show comments only Show history only
- Michael Martin changed the description
Compare with previous version changed the description
Other similarly configured SAML apps: Nexus, Twistlock. We wrote manual configure jobs for both of those and Sonarqube as mentioned in the issue description. Happy to contribute the API curls we used to plug the metadata into the app in real time after Keycloak is up via some kind of configure-sso job (e.g. like Anchore).
- bigbang bot added teamDevelopment & Ops label
added teamDevelopment & Ops label
- bigbang bot added triage-kind label
added triage-kind label
- bigbang bot added triage-priority label
added triage-priority label
Sonarqube example (excerpt from an ansible playbook):
- name: Enable SAML block: - name: Get the x509 certificate from keycloak ansible.builtin.uri: url: "https://{{ keycloak.url }}.{{ domain }}/auth/realms/{{ keycloak.realm_name }}/protocol/saml/descriptor" method: GET dest: "{{ scratch_dir }}/keycloak_x509.xml" register: get_x509_cert until: get_x509_cert.status == 200 retries: 30 delay: 20 - name: Extract keycloak cert from xml xml: namespaces: md: "urn:oasis:names:tc:SAML:2.0:metadata" saml: "urn:oasis:names:tc:SAML:2.0:assertion" ds: "http://www.w3.org/2000/09/xmldsig#" path: "{{ scratch_dir }}/keycloak_x509.xml" xpath: /md:EntityDescriptor/md:IDPSSODescriptor/md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate content: text register: x509_cert_xml - name: Pull keycloak cert value out from key ansible.builtin.set_fact: x509_cert: "{{ x509_cert_xml.matches.0['{http://www.w3.org/2000/09/xmldsig#}X509Certificate'] }}" - name: save cert to sonarqube to enable SAML ansible.builtin.shell: | curl -X POST -u {{ sonarqube_user }}:{{ sonarqube_password }} "https://{{ sonarqube.url }}.{{ domain }}/api/settings/set" \ --data-urlencode "key={{ sonarqube_key }}" \ --data-urlencode "value={{ x509_cert }}" \
Nexus example (excerpt from an ansible playbook):
- name: Configure saml for Nexus block: - name: Check if IDP metadata already configured ansible.builtin.uri: url: "https://{{ nexus.url }}.{{ domain }}/service/rest/v1/security/saml/metadata" method: GET body_format: json status_code: [200, 202, 404] return_content: yes url_username: "{{ nexus_user }}" url_password: "{{ nexus_password }}" force_basic_auth: true register: saml_configured until: saml_configured is not failed retries: 10 delay: 30 when: nexus_licensed|bool - name: Configure saml for Nexus block: - name: Check that realm exists ansible.builtin.uri: url: https://{{ keycloak.url }}.{{ domain }}/auth/realms/{{ keycloak.realm_name }} register: realm until: realm is not failed retries: 10 delay: 30 - name: Get IDP metadata from Keycloak ansible.builtin.shell: | kubectl --kubeconfig {{ kubeconfig }} exec -n {{ keycloak.namespace }} keycloak-0 -c {{ keycloak.namespace }} -- bash -c "cd /tmp && curl -OJ http://localhost:8080/auth/realms/{{ keycloak.realm_name }}/protocol/saml/descriptor" kubectl --kubeconfig {{ kubeconfig }} exec -n {{ keycloak.namespace }} keycloak-0 -c {{ keycloak.namespace }} -- bash -c "cat /tmp/descriptor" > {{ scratch_dir }}/idpMetadata.xml - name: Stat IDP metadata file ansible.builtin.stat: path: "{{ scratch_dir }}/idpMetadata.xml" register: stat_result failed_when: not stat_result.stat.exists - name: Update Keycloak URL in metadata file ansible.builtin.replace: path: "{{ scratch_dir }}/idpMetadata.xml" regexp: 'http:\/\/localhost:8080' replace: "https://{{ keycloak.url }}.{{ domain }}" - name: Template saml.json vars: idp_metadata: "{{ lookup('file', '{{ scratch_dir }}/idpMetadata.xml') | to_json }}" ansible.builtin.template: src: nexus/nexus-saml.j2 dest: "{{ scratch_dir }}/nexus-saml.json" mode: "0644" - name: Inject IDP metadata into NXRM ansible.builtin.uri: url: "https://{{ nexus.url }}.{{ domain }}/service/rest/v1/security/saml" force_basic_auth: yes user: "{{ nexus_user }}" password: "{{ nexus_password }}" method: PUT body_format: json status_code: [200, 201, 204] return_content: yes headers: Content-Type: application/json accept: application/json body: "{{ lookup('file','{{ scratch_dir }}/nexus-saml.json') }}" - name: Get keycloak user ansible.builtin.shell: cmd: kubectl --kubeconfig {{ kubeconfig }} get secret keycloak-env -n {{ keycloak.namespace }} -o=jsonpath='{.data.KEYCLOAK_ADMIN}' register: keycloak_user - name: Get keycloak pass ansible.builtin.shell: cmd: kubectl --kubeconfig {{ kubeconfig }} get secret keycloak-env -n {{ keycloak.namespace }} -o=jsonpath='{.data.KEYCLOAK_ADMIN_PASSWORD}' register: keycloak_pass - name: Set keycloak fact ansible.builtin.set_fact: keyusername: "{{ keycloak_user.stdout | string | b64decode }}" keypasswd: "{{ keycloak_pass.stdout | string | b64decode }}" - name: Get keycloak token ansible.builtin.uri: url: https://{{ keycloak.url }}.{{ domain }}/auth/realms/master/protocol/openid-connect/token method: POST body_format: form-urlencoded body: username: "{{ keyusername }}" password: "{{ keypasswd }}" grant_type: password client_id: admin-cli register: keycloak_token_nexus - name: Get NXRM pod name ansible.builtin.shell: cmd: kubectl --kubeconfig {{ kubeconfig }} -n nexus-repository-manager get pods -l=app=nexus-repository-manager -o jsonpath='{.items[0].metadata.name}' register: nxrm_pod - name: Get SAML metadata from NXRM (for Keycloak) ansible.builtin.shell: | kubectl --kubeconfig {{ kubeconfig }} exec {{ nxrm_pod.stdout }} -n nexus-repository-manager -c nexus-repository-manager -- bash -c "cd /tmp && curl -sf -u {{ nexus_user }}:{{ nexus_password }} -OJ http://localhost:8081/service/rest/v1/security/saml/metadata" register: curl_result retries: 5 delay: 10 until: curl_result is not failed - name: Check if /tmp/metadata exists in Nexus Container ansible.builtin.shell: | kubectl --kubeconfig {{ kubeconfig }} exec {{ nxrm_pod.stdout }} -n nexus-repository-manager -c nexus-repository-manager -- test -f /tmp/metadata register: check_file retries: 5 delay: 10 until: check_file is not failed - name: Copy NXRM SAML metadata file (for Keycloak) ansible.builtin.shell: | kubectl --kubeconfig {{ kubeconfig }} cp nexus-repository-manager/{{ nxrm_pod.stdout }}:/tmp/metadata -c nexus-repository-manager {{ scratch_dir }}/samlMetadata.xml - name: Stat NXRM SAML metadata file (for Keycloak) ansible.builtin.stat: path: "{{ scratch_dir }}/samlMetadata.xml" register: saml_stat_result failed_when: not saml_stat_result.stat.exists - name: Get signing cert from XML xml: path: "{{ scratch_dir }}/samlMetadata.xml" xpath: /md:EntityDescriptor/md:SPSSODescriptor/md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate namespaces: md: urn:oasis:names:tc:SAML:2.0:metadata ds: http://www.w3.org/2000/09/xmldsig# content: text register: nexus_signing_cert - name: Get encryption cert from XML xml: path: "{{ scratch_dir }}/samlMetadata.xml" xpath: /md:EntityDescriptor/md:SPSSODescriptor/md:KeyDescriptor[@use='encryption']/ds:KeyInfo/ds:X509Data/ds:X509Certificate namespaces: md: urn:oasis:names:tc:SAML:2.0:metadata ds: http://www.w3.org/2000/09/xmldsig# content: text register: nexus_encryption_cert - name: Get list of Keycloak flows ansible.builtin.uri: url: "https://{{ keycloak.url }}.{{ domain }}/auth/admin/realms/{{ keycloak.realm_name }}/authentication/flows" method: GET headers: Authorization: "Bearer {{ keycloak_token_nexus.json.access_token }}" status_code: [200, 201] register: flows_output retries: 60 delay: 10 until: flows_output is not failed - name: Store custom flow ID ansible.builtin.set_fact: customflowid: "{{ flows_output.json | json_query('[? alias==`P1 Authentication No Group Authz`].id') }}" - name: Delete existing client from Keycloak ansible.builtin.uri: url: "https://{{ keycloak.url }}.{{ domain }}/auth/admin/realms/{{ keycloak.realm_name }}/clients/{{ keycloak_nexus_client_id }}" method: DELETE headers: Authorization: "Bearer {{ keycloak_token_nexus.json.access_token }}" status_code: [200, 204, 404] # 404 means it was already deleted or doesn't exist yet register: delete_client_output retries: 3 delay: 5 until: delete_client_output is not failed - name: Create client in Keycloak (if needed) ansible.builtin.uri: url: "https://{{ keycloak.url }}.{{ domain }}/auth/admin/realms/{{ keycloak.realm_name }}/clients" method: POST body_format: json headers: Authorization: "Bearer {{ keycloak_token_nexus.json.access_token }}" body: { "id": "{{ keycloak_nexus_client_id }}", "clientId": "https://{{ nexus.url }}.{{ domain }}/service/rest/v1/security/saml/metadata", "adminUrl": "https://{{ nexus.url }}.{{ domain }}/saml", "surrogateAuthRequired": false, "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", "secret": "**********", "redirectUris": ["https://{{ nexus.url }}.{{ domain }}/saml"], "webOrigins": ["https://{{ nexus.url }}.{{ domain }}"], "notBefore": 0, "bearerOnly": false, "consentRequired": false, "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, "publicClient": false, "frontchannelLogout": true, "protocol": "saml", "attributes": { "saml.force.post.binding": "true", "saml.multivalued.roles": "false", "oauth2.device.authorization.grant.enabled": "false", "backchannel.logout.revoke.offline.tokens": "false", "saml.server.signature.keyinfo.ext": "false", "use.refresh.tokens": "true", "saml.signing.certificate": "{{ nexus_signing_cert.matches.0['{http://www.w3.org/2000/09/xmldsig#}X509Certificate'] }}", "oidc.ciba.grant.enabled": "false", "backchannel.logout.session.required": "false", "client_credentials.use_refresh_token": "false", "saml.signature.algorithm": "RSA_SHA256", "saml.client.signature": "true", "id.token.as.detached.signature": "false", "saml.assertion.signature": "true", "saml_single_logout_service_url_post": "https://{{ nexus.url }}.{{ domain }}/saml", "saml.encrypt": "true", "saml_assertion_consumer_url_post": "https://{{ nexus.url }}.{{ domain }}/saml", "saml.server.signature": "true", "exclude.session.state.from.auth.response": "false", "saml.artifact.binding.identifier": "yDQntiRCyi+JIMb+qGKOOVrXB6o=", "saml.artifact.binding": "false", "saml_force_name_id_format": "false", "saml.encryption.certificate": "{{ nexus_encryption_cert.matches.0['{http://www.w3.org/2000/09/xmldsig#}X509Certificate'] }}", "tls.client.certificate.bound.access.tokens": "false", "saml.authnstatement": "true", "display.on.consent.screen": "false", "saml_name_id_format": "username", "saml.onetimeuse.condition": "false", "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#", }, "authenticationFlowBindingOverrides": {"browser": "{{ customflowid[0] }}"}, "fullScopeAllowed": true, "nodeReRegistrationTimeout": -1, "protocolMappers": [ { "id": "4a0f301f-4242-4ef9-8588-bd81a0d76a44", "name": "Roles", "protocol": "saml", "protocolMapper": "saml-role-list-mapper", "consentRequired": false, "config": { "single": "true", "attribute.nameformat": "Basic", "friendly.name": "roles", "attribute.name": "roles" } }, { "id": "1a73f279-8656-487c-8d9d-77ba161b1a42", "name": "Last Name", "protocol": "saml", "protocolMapper": "saml-user-property-mapper", "consentRequired": false, "config": { "attribute.nameformat": "Basic", "user.attribute": "lastName", "friendly.name": "lastName", "attribute.name": "lastName", }, }, { "id": "2a51e0d1-06f5-4b4b-ae07-950df979ee4e", "name": "Email", "protocol": "saml", "protocolMapper": "saml-user-property-mapper", "consentRequired": false, "config": { "attribute.nameformat": "Basic", "user.attribute": "email", "friendly.name": "email", "attribute.name": "email", }, }, { "id": "ea063ec8-89e8-49a0-88c3-7e1b961c7b62", "name": "username", "protocol": "saml", "protocolMapper": "saml-user-property-mapper", "consentRequired": false, "config": { "attribute.nameformat": "Basic", "user.attribute": "username", "friendly.name": "username", "attribute.name": "username", }, }, { "id": "3eb3e52c-3aa8-4aa4-8cb2-5f4cc7bb71ad", "name": "First Name", "protocol": "saml", "protocolMapper": "saml-user-property-mapper", "consentRequired": false, "config": { "attribute.nameformat": "Basic", "user.attribute": "firstName", "friendly.name": "firstName", "attribute.name": "firstName", }, }, ], "defaultClientScopes": [], "optionalClientScopes": [], } status_code: [200, 201] register: create_client_output retries: 3 delay: 5 until: create_client_output is not failed when: - nexus_licensed|bool
Which uses a template
nexus-saml.j2
:{ "usernameAttribute": "username", "firstNameAttribute": "firstName", "lastNameAttribute": "lastName", "emailAttribute": "email", "groupsAttribute": "roles", "validateResponseSignature": true, "validateAssertionSignature": true, "idpMetadata": {{ idp_metadata }} }
Twistlock example (excerpt from an ansible playbook):
- name: Configure Twistlock SAML for Keycloak block: - name: Get idp.xml ansible.builtin.shell: | kubectl --kubeconfig {{ kubeconfig }} exec -n keycloak keycloak-0 -c keycloak -- bash -c "cd /tmp && curl -OJ http://localhost:8080/auth/realms/{{ keycloak.realm_name }}/protocol/saml/descriptor" kubectl --kubeconfig {{ kubeconfig }} exec -n {{ keycloak.namespace }} keycloak-0 -c {{ keycloak.namespace }} -- bash -c "cat /tmp/descriptor" > {{ scratch_dir }}/idp.xml - name: stat idp.xml ansible.builtin.stat: path: "{{ scratch_dir }}/idp.xml" register: stat_result failed_when: not stat_result.stat.exists - name: get x509 ansible.builtin.shell: "{% if ansible_distribution == 'MacOSX' %}cat {{ scratch_dir }}/idp.xml | ggrep -oP '(?<=<ds:X509Certificate>).*?(?=</ds:X509Certificate>)'{% else %}cat {{ scratch_dir }}/idp.xml | grep -oP '(?<=<ds:X509Certificate>).*?(?=</ds:X509Certificate>)'{% endif %}" register: x509 no_log: false - name: Set x509cert fact ansible.builtin.set_fact: x509crt: "{{ x509.stdout }}" when: x509.rc==0 no_log: false - name: Template saml.json ansible.builtin.template: src: twistlock/twistlock-saml.j2 dest: "{{ scratch_dir }}/saml.json" mode: "0644" - name: Add saml settings ansible.builtin.uri: url: "https://{{ twistlock.url }}.{{ domain }}/api/v1/settings/saml" force_basic_auth: yes user: "{{ twistlock.user }}" password: "{{ twistlock_pass }}" method: POST body_format: json status_code: [200, 202] return_content: yes headers: Content-Type: application/json body: "{{ lookup('file','{{ scratch_dir }}/saml.json') }}" when: - stat_result.stat.exists - name: Remove saml.json ansible.builtin.file: path: "{{ scratch_dir }}/saml.json" state: absent
Which uses a template
twistlock-saml.j2
:{ "enabled": true, "url": "https://{{ keycloak.url }}.{{ domain }}/auth/realms/{{ keycloak.realm_name }}/protocol/saml", "cert": "-----BEGIN CERTIFICATE-----\n{{ x509crt }}\n-----END CERTIFICATE-----", "issuer": "https://{{ keycloak.url }}.{{ domain }}/auth/realms/{{ keycloak.realm_name }}", "type": "shibboleth", "audience": "{{ twistlock.saml_audience }}", "appId": "", "tenantId": "", "appSecret": { "encrypted": "" }, "providerAlias": "JCN Keycloak", "consoleURL": "https://{{ twistlock.url }}.{{ domain }}" }
- Matt Vasquez removed triage-kind label
removed triage-kind label
- Matt Vasquez added kindfeature label
added kindfeature label
- Matt Vasquez removed triage-priority label
removed triage-priority label
- Matt Vasquez added priority4 label
added priority4 label
- Matt Vasquez changed iteration to Big Bang Iterations May 28, 2024 - Jun 10, 2024
changed iteration to Big Bang Iterations May 28, 2024 - Jun 10, 2024
- Matt Vasquez changed milestone to %2.29.0
changed milestone to %2.29.0
- GitLab Automation Bot removed iteration Big Bang Iterations May 28, 2024 - Jun 10, 2024
removed iteration Big Bang Iterations May 28, 2024 - Jun 10, 2024
- GitLab Automation Bot changed iteration to Big Bang Iterations Jun 11, 2024 - Jun 24, 2024
changed iteration to Big Bang Iterations Jun 11, 2024 - Jun 24, 2024
- Daniel Stocum assigned to @daniel.stocum
assigned to @daniel.stocum
- Daniel Stocum set weight to 3
set weight to 3
- Daniel Stocum added statusdoing label
added statusdoing label