Skip to content

NethVoice: pluggable authentication methods with SAML2 Single Sign-On #8142

Closed

Description

@edospadoni

Description

NethVoice CTI users can currently authenticate only with their account-provider password (PAM/LDAP bind). Organizations using a federated Identity Provider (e.g. universities with Shibboleth/SAML2, as in legacy NethVoice 7 deployments integrated with the Cineca IdP) need users to log into NethVoice CTI through the IdP, without typing a password in NethVoice.

Goal: introduce a generic, pluggable "authentication method" in the NS8 NethVoice module: SAML2 now, ready for OIDC later, with minimal changes to the core components.

Design

Two orthogonal concepts:

  • Account provider (existing, unchanged): where users live (NS8 OpenLDAP or external LDAP/AD). Still required with SSO: it remains the source of user records, extensions and CTI profiles.
  • Authentication method (new): how users prove their identity: password (default, current behavior) or saml2 (delegated to an external IdP). The SAML identity attribute (e.g. uid, eppn) must match the account-provider username.

Trusted-mint contract (protocol agnostic):

  1. A Shibboleth SP container, generated from the module settings, authenticates the user against the IdP.
  2. Traefik guards a dedicated mint route (POST /api/sso-login) with forwardAuth against the SP check endpoint; the verified identity is injected as Remote-User (client-supplied values are overridden, the SP fails closed on spoofing).
  3. nethcti-middleware forwards the identity with a shared secret to nethcti-server, which mints the token without a password.

Adding OIDC later only requires a new front door (e.g. oauth2-proxy) speaking the same contract.

Security

  • Per-install high-entropy secrets generated by the module (mint-authorization secret and a separate HMAC token key), never shown in the UI, revoked when SSO is disabled.
  • Privileged/local accounts (admin, administrator, root) always denied over SSO; optional user allowlist.
  • SP attribute filter scoped to the single identity attribute, single-attribute REMOTE_USER, optional value regex.
  • Optional IdP metadata signature verification (federation signing certificate, settable via API).
  • SSO is off by default: with authentication_method=password nothing changes.

User experience

  • Cluster-admin settings: "Authentication method" selector; with saml2: the service-provider metadata URL to register on the IdP (copy to clipboard), IdP metadata URL, identity attribute, SSO button label.
  • CTI login page: SSO button instead of the password form; https://<cti>/?login=sso starts the flow directly (like the legacy ?login=shibboleth).
  • NethLink: two-step login (host first, then credentials or SSO button based on the host authentication method); the SAML flow runs in a dedicated window and SSO accounts are stored without a password.

Validated end-to-end on NS8 with a Shibboleth SP and a SimpleSAMLphp test IdP (login, phone-island and calls working).

Activity

  1. added
    nethvoiceBug or features releted to the NethVoice project
    on Sep 2, 2026
  2. self-assigned this
    on Sep 21, 2026
  3. moved this from Todo to In Progress in NethVoiceon Sep 21, 2026
  4. edospadoni commented on Oct 5, 2026

    @edospadoni
    MemberAuthor
  5. removed their assignment
    on Oct 5, 2026
  6. added
    testingPackages are available from testing repositories
    on Oct 5, 2026
  7. edospadoni commented on Oct 8, 2026

    @edospadoni
    MemberAuthor

    Test cases

    Update NethVoice to the testing version:

    update-module ghcr.io/nethesis/nethvoice:1.7.10-testing.27 nethvoice1
    

    Requirements:

    • NethVoice configured, with valid certificates on the NethVoice host and on the CTI host
    • an account provider user edoardo with a CTI profile and a NethLink extension
    • no user edoardo2 in the account provider

    Set these variables on the node (as root), they are used in all the commands below:

    export VOICE_HOST=voice.example.org CTI_HOST=cti.example.org MODULE=nethvoice1
    

    The two test identity providers run on the same node and are published under the NethVoice host, so they reuse its certificate:

    • SAML2 IdP (SimpleSAMLphp): https://$VOICE_HOST/simplesaml, users edoardo/edoardopass and edoardo2/edoardopass2
    • OIDC IdP (dex): https://$VOICE_HOST/dex, user edoardo@example.org/edoardopass
    Setup: SAML2 test IdP (SimpleSAMLphp)
    mkdir -p /root/sso-test/saml && cd /root/sso-test/saml
    
    cat > authsources.php <<'EOF'
    <?php
    $config = array(
        'admin' => array('core:AdminPassword'),
        'example-userpass' => array(
            'exampleauth:UserPass',
            'edoardo:edoardopass' => array('uid' => array('edoardo'), 'email' => 'edoardo@example.org'),
            'edoardo2:edoardopass2' => array('uid' => array('edoardo2'), 'email' => 'edoardo2@example.org'),
        ),
    );
    EOF
    
    cat > saml20-idp-hosted.php <<EOF
    <?php
    \$metadata['__DYNAMIC:1__'] = array(
        'host' => '__DEFAULT__',
        'privatekey' => 'server.pem',
        'certificate' => 'server.crt',
        'auth' => 'example-userpass',
        'UIInfo' => array(
            'DisplayName' => array('en' => 'NethVoice Lab IdP'),
            'Logo' => array(array('url' => 'https://${VOICE_HOST}/simplesaml/resources/icons/ssplogo-fish-small.png', 'height' => 32, 'width' => 74)),
        ),
    );
    EOF
    
    cat > Containerfile <<'EOF'
    FROM docker.io/kristophjunge/test-saml-idp:latest
    ARG VOICE_HOST
    COPY authsources.php /var/www/simplesamlphp/config/authsources.php
    COPY saml20-idp-hosted.php /var/www/simplesamlphp/metadata/saml20-idp-hosted.php
    RUN sed -i "s#'baseurlpath' => '[^']*'#'baseurlpath' => 'https://${VOICE_HOST}/simplesaml/'#" /var/www/simplesamlphp/config/config.php
    EOF
    
    podman build --build-arg VOICE_HOST=$VOICE_HOST -t localhost/sso-test-idp .
    
    podman run -d --name sso-test-idp --restart=always -p 127.0.0.1:20081:8080 \
      -e SIMPLESAMLPHP_SP_ENTITY_ID=https://$CTI_HOST/shibboleth \
      -e SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=https://$CTI_HOST/Shibboleth.sso/SAML2/POST \
      -e SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=https://$CTI_HOST/Shibboleth.sso/SLO/Redirect \
      localhost/sso-test-idp
    
    cat > /home/traefik1/.config/state/configs/zz-sso-test-idp.yml <<EOF
    http:
      services:
        sso-test-idp:
          loadBalancer:
            servers:
            - url: http://127.0.0.1:20081
      routers:
        sso-test-idp-https:
          rule: Host(\`$VOICE_HOST\`) && PathPrefix(\`/simplesaml\`)
          priority: 100
          service: sso-test-idp
          entryPoints:
          - https
          tls:
            domains:
            - main: $VOICE_HOST
    EOF
    chown traefik1:traefik1 /home/traefik1/.config/state/configs/zz-sso-test-idp.yml
    

    Check, the IdP metadata must be returned:

    curl -s https://$VOICE_HOST/simplesaml/saml2/idp/metadata.php | grep entityID
    
    Setup: OIDC test IdP (dex)
    mkdir -p /root/sso-test/dex && cd /root/sso-test/dex
    HASH=$(podman run --rm docker.io/httpd:2-alpine htpasswd -nbBC 10 x edoardopass | cut -d: -f2)
    CLIENT_SECRET=$(head -c24 /dev/urandom | base64 | tr '+/' 'AB' | tr -d '=')
    echo "Client secret: $CLIENT_SECRET"
    
    cat > config.yaml <<EOF
    issuer: https://$VOICE_HOST/dex
    storage:
      type: memory
    web:
      http: 0.0.0.0:8080
    oauth2:
      skipApprovalScreen: true
      passwordConnector: local
    staticClients:
      - id: nethvoice-cti
        name: NethVoice CTI
        secret: "$CLIENT_SECRET"
        redirectURIs:
          - https://$CTI_HOST/oauth2/callback
    enablePasswordDB: true
    staticPasswords:
      - email: "edoardo@example.org"
        hash: "$HASH"
        username: "edoardo"
        userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
    EOF
    
    podman run -d --name dex-test --restart=always -p 127.0.0.1:20082:8080 \
      -v /root/sso-test/dex/config.yaml:/etc/dex/config.yaml:ro,z \
      ghcr.io/dexidp/dex:v2.41.1 dex serve /etc/dex/config.yaml
    
    cat > /home/traefik1/.config/state/configs/zz-dex-test-idp.yml <<EOF
    http:
      services:
        dex-test-idp:
          loadBalancer:
            servers:
            - url: http://127.0.0.1:20082
      routers:
        dex-test-idp-https:
          rule: Host(\`$VOICE_HOST\`) && PathPrefix(\`/dex\`)
          priority: 100
          service: dex-test-idp
          entryPoints:
          - https
          tls:
            domains:
            - main: $VOICE_HOST
    EOF
    chown traefik1:traefik1 /home/traefik1/.config/state/configs/zz-dex-test-idp.yml
    

    Check, the issuer must be returned:

    curl -s https://$VOICE_HOST/dex/.well-known/openid-configuration | grep issuer
    

    Test case 1: SSO hidden by default

    • Open NethVoice Settings
    • The "Authentication method" field must not be there
    • CTI and NethLink must still ask username and password

    Test case 2: enable SSO

    • Enable the feature flag:
    runagent -m $MODULE python3 -c 'import agent; agent.set_env("SSO_AUTH", "true")'
    
    • Reload NethVoice Settings: the "Authentication method" field must be there, with "Local password", "SAML2 Single Sign-On" and "OpenID Connect (OAuth2)"

    Test case 3: SAML2 configuration

    • Select "SAML2 Single Sign-On"
    • The "Service provider metadata URL" https://$CTI_HOST/Shibboleth.sso/Metadata must be shown, with a copy button
    • Set the IdP metadata URL https://$VOICE_HOST/simplesaml/saml2/idp/metadata.php
    • The login button preview must show "Sign in with SSO" and, below it, "NethVoice Lab IdP" with its logo
    • Set a wrong URL: the preview must show "Preview not available"
    • Set back the right URL, identity attribute uid, save
    • The SP must be downloaded and running:
    runagent -m $MODULE systemctl --user is-active saml2-proxy.service
    curl -s -o /dev/null -w '%{http_code}\n' https://$CTI_HOST/Shibboleth.sso/Metadata
    
    • After a reload the field must still show "SAML2 Single Sign-On"

    Test case 4: SAML2 login on CTI

    • Open https://$CTI_HOST in a private window
    • Only the SSO button must be shown, with the IdP name and logo below it, readable in light and dark theme
    • Click it and log in with edoardo/edoardopass
    • You must be logged into the CTI as edoardo; open the phone island and make a call

    Test case 5: SAML2 user not enabled on CTI

    • Log out and log in with edoardo2/edoardopass2
    • You must be back on the CTI login page with the message "You signed in on the identity provider, but this user is not enabled to use the CTI. Contact your administrator."

    Test case 6: SAML2 direct login link

    • Log out and open https://$CTI_HOST/?login=sso
    • You must be redirected to the IdP login page without clicking the button

    Test case 7: SAML2 login on NethLink

    • In NethLink add an account, enter $CTI_HOST and continue
    • Only the SSO button must be shown, without username and password
    • Click it: a window with the IdP login page opens, log in with edoardo/edoardopass
    • NethLink must log in as edoardo
    • Repeat with edoardo2/edoardopass2: the "not enabled to use the CTI" message must be shown
    • Close the IdP window before logging in: you must be back on the form, without errors

    Test case 8: OIDC configuration

    • In NethVoice Settings select "OpenID Connect (OAuth2)"
    • The "Redirect URI (callback)" https://$CTI_HOST/oauth2/callback must be shown, with a copy button
    • Set issuer URL https://$VOICE_HOST/dex, client ID nethvoice-cti, client secret printed by the dex setup, IdP name Dex Test IdP, save
    • oauth2-proxy must be downloaded and running, the SAML2 SP must be stopped:
    runagent -m $MODULE systemctl --user is-active oauth2-proxy.service saml2-proxy.service
    
    • After a reload the field must show "OpenID Connect (OAuth2)", the client secret must be hidden and shown by the eye icon

    Test case 9: OIDC login on CTI and NethLink

    • dex test users do not send the preferred_username claim used by NethVoice, real IdPs (Entra ID, Keycloak, Okta, Google) do. For dex only, after every save of the OIDC settings run:
    U=/home/$MODULE/.config/systemd/user/oauth2-proxy.service
    grep -q OIDC_EMAIL_CLAIM $U || sed -i 's#^\(\s*\)\${NETHVOICE_OAUTH2_PROXY_IMAGE}#\1"--env=OAUTH2_PROXY_OIDC_EMAIL_CLAIM=name" \\\n\1${NETHVOICE_OAUTH2_PROXY_IMAGE}#' $U
    runagent -m $MODULE python3 -c 'import agent; agent.set_env("NETHCTI_SSO_USER_HEADER", "X-Auth-Request-Email")'
    sed -i 's/X-Auth-Request-Preferred-Username/X-Auth-Request-Email/' /home/traefik1/.config/state/configs/$MODULE-sso-mint.yml
    runagent -m $MODULE systemctl --user daemon-reload
    runagent -m $MODULE systemctl --user restart oauth2-proxy.service nethcti-middleware.service
    
    • Open https://$CTI_HOST in a private window, click the SSO button ("Dex Test IdP" below it), log in with edoardo@example.org/edoardopass and grant access
    • You must be logged into the CTI as edoardo
    • Repeat the login on NethLink as in test case 7: it must log in as edoardo

    Test case 10: the SSO login can not be forged

    • With SAML2 or OIDC active run:
    MW=$(runagent -m $MODULE bash -c 'echo $NETHVOICE_MIDDLEWARE_PORT')
    curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:$MW/api/sso-login -H 'Remote-User: edoardo' -H 'X-Auth-Request-Preferred-Username: edoardo'
    curl -s -o /dev/null -w '%{http_code}\n' -X POST https://$CTI_HOST/api/sso-login -H 'Remote-User: edoardo'
    
    • Both must return 401

    Test case 11: secrets and ports

    • The SSO secrets must not be in the module environment, which is exported to Redis, but only in passwords.env:
    grep -c NETHCTI_SSO_TRUSTED_SECRET /home/$MODULE/.config/state/environment /home/$MODULE/.config/state/passwords.env
    
    • environment must return 0, passwords.env must return 1
    • The two SSO ports must be inside the module port range:
    runagent -m $MODULE bash -c 'echo $TCP_PORTS_RANGE $SAML2_PROXY_PORT $OAUTH2_PROXY_PORT'
    

    Test case 12: back to password

    • Select "Local password" and save
    • saml2-proxy and oauth2-proxy must be stopped and their images removed:
    runagent -m $MODULE podman images | grep -E 'saml2-proxy|oauth2-proxy'
    
    • CTI and NethLink must ask username and password again, and the login must work

    Test case 13: disable SSO

    • Disable the feature flag:
    runagent -m $MODULE python3 -c 'import agent; agent.set_env("SSO_AUTH", "false")'
    
    • Reload NethVoice Settings: the "Authentication method" field must be gone
    • Saving the settings must keep the password login

    Test case 14: backup and restore

    • With SAML2 active, take note of the SP certificate:
    sha256sum /home/$MODULE/.config/state/saml2-proxy/sp-cert.pem
    
    • Run a backup and restore it on another node
    • The checksum must be the same and the SAML2 login must work without registering the SP again on the IdP

    Test case 15: update from a previous version

    • Install the previous NethVoice version, complete the wizard, then update to this version
    • The authentication method must stay "Local password", SSO_AUTH must be unset and the SSO images must not be downloaded
    • The two SSO ports must be allocated inside the module port range, see test case 11
    Cleanup
    podman rm -f sso-test-idp dex-test
    podman rmi localhost/sso-test-idp ghcr.io/dexidp/dex:v2.41.1
    rm -f /home/traefik1/.config/state/configs/zz-sso-test-idp.yml /home/traefik1/.config/state/configs/zz-dex-test-idp.yml
    rm -rf /root/sso-test
    
  8. tonyco97 commented on Oct 8, 2026

    @tonyco97

    Test report

    Environment

    Results

    Test case Result
    1. SSO hidden by default ✅
    2. Enable SSO ✅
    3. SAML2 configuration ✅ SP metadata URL with copy button, IdP name and logo in the preview, "Preview not available" with a wrong URL, SP running, setting kept after reload
    4. SAML2 login on CTI ✅ SSO button only, readable in light and dark theme, login and call OK
    5. SAML2 user not enabled on CTI ✅
    6. SAML2 direct login link ✅
    7. SAML2 login on NethLink ✅ login OK, "not enabled" banner shown, closing the IdP window returns to the form
    8. OIDC configuration ✅ redirect URI with copy button, oauth2-proxy running, SAML2 SP stopped, secret hidden/shown
    9. OIDC login on CTI and NethLink ✅ with the dex workaround; after logout the IdP asks the credentials again
    10. SSO login can not be forged ✅ both requests return 401
    11. Secrets and ports ✅ secret only in passwords.env (environment: 0, passwords.env: 1)
    12. Back to password ✅ both units stopped, images removed, SSO routes removed, password login OK on CTI and NethLink
    13. Disable SSO ✅ field hidden, save keeps the password login
  9. charliewhiting commented on Oct 8, 2026

    @charliewhiting

    Package installed correctly at the university site.
    SSO disabled by default.

    Command executed:
    runagent -m nethvoice1 python3 -c 'import agent; agent.set_env("SSO_AUTH", "true")'

    The command returned the SSO configuration parameters, and the values were still set to the previous ones.

    Login was verified and completed successfully through redirection to the customer's authentication platform.

  10. removed
    testingPackages are available from testing repositories
    on Oct 9, 2026
  11. moved this from Testing to Verified in NethVoiceon Oct 9, 2026
  12. Amygos commented on Oct 12, 2026

    @Amygos
    Member

    Release nethesis/ns8-nethvoice 1.7.10

  13. removed
    verifiedAll test cases were verified successfully
    on Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    nethvoiceBug or features releted to the NethVoice project

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions