Setting up SAML for Kimai time tracker using Zitadel IDP

A few days ago I decided to install Kimai time tracker software onto my home lab. I'm working on a personal project that would require external paid contributors, so, naturally, I want a system to track the time, and in vein will all my other tooling - it has to be self-hostable.

Most self-hostable SaaS products have some form of SSO, usually OIDC, but in this case Kimai is using SAML. Thankfully, Zitadel supports SAML. Now it's a matter of putting it all together.

Getting Kimai docker setup

I'm using Portainer stacks, which allows use of docker-compose.yml style set up with some caveats. Because I need to supply a custom configuration file to the container, and Portainer does not really support that, I ended up leveraging Docker Configs for the purpose. It allows to provide file content inline as part of the compose file.

I took the default compose from Kimai and modified it to add the SAML configuration and added the configs section under kimai service like so:

services:
  ...
  kimai:
    ...
    configs:
      - source: local
        target: /opt/kimai/config/packages/local.yaml
        uid: "33"
        gid: "33"

and a global configs section as well, like so:

...
configs:
  local:
    content: |
      kimai:
          saml:
            ...

Chicken and Egg problem

If the Kimai's SAML configuration is incomplete, it does not enable any of the SAML endpoints and we can't trivially get the metadata necessary for SAML setup on Zitadel side. Thankfully, we can do a partial setup by simply providing ACS and entityId values. It took me some attempts to understand what the proper values are, so you get to cut it short, since I've done all the research for you.

The entityId would be https://<YOUR_KIMAI_DOMAIN>/auth/saml/metadata.
The ACS Endpoint would be https://<YOUR_KIMAI_DOMAIN>/auth/saml/acs.
Simply populate the SAML application settings in Zitadel with those values to get it provisioned:

Once provisioned, you can download the X.509 certificate that would be necessary to complete the setup on Kimai side.

We have the chicken, onto the egg

With certificate in hand, this is what my resulting local.yaml looks like

configs:
  local:
    content: |
      kimai:
          saml:
              provider: passport # This is used for a FontAwesome Icon
              activate: true
              title: Login with Zitadel
              mapping:
                  - { saml: $$Email, kimai: email } # Double $$ to avoid interpolation
                  - { saml: $$FullName, kimai: alias } # Zitadel uses FullName attribute, you can also use $$FirstName $$SurName
              roles:
                  resetOnLogin: true
                  attribute: Roles
                  mapping:
                      # Insert your role-mapping here (ROLE_USER is added automatically)
                      - { saml: $PROJECT_ID:kimai-superadmin, kimai: ROLE_SUPER_ADMIN }
                      - { saml: $PROJECT_ID:kimai-admin, kimai: ROLE_ADMIN }
                      - { saml: $PROJECT_ID:kimai-teamlead, kimai: ROLE_TEAMLEAD }
              connection:
                  idp:
                      entityId: 'https://<YOUR_ZITADEL_DOMAIN>/saml/v2/metadata'
                      singleSignOnService:
                          url: 'https://<YOUR_ZITADEL_DOMAIN>/saml/v2/SSO'
                          binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
                      #singleLogoutService:
                      #    url: 'https://<YOUR_ZITADEL_DOMAIN>/saml/v2/SLO'
                      #    binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
                      x509cert: |
                          -----BEGIN CERTIFICATE-----
                          ... Insert Certificate Here and indent everything
                          -----END CERTIFICATE-----
                  # Your Kimai: replace https://www.example.com with your base URL
                  sp:
                      entityId: 'https://<YOUR_KIMAI_URL>/auth/saml/metadata'
                      assertionConsumerService:
                          url: 'https://<YOUR_KIMAI_URL>/auth/saml/acs'
                          binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
                      # singleLogoutService:
                      #     url: 'https://<YOUR_KIMAI_URL>/auth/saml/logout'
                      #     binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
                      #privateKey: ''
                  # only set baseurl, if auto-detection doesn't work
                  baseurl: 'https://<YOUR_KIMAI_URL>'
                  strict: true
                  debug: true
                  security:
                      nameIdEncrypted: false
                      authnRequestsSigned: false
                      logoutRequestSigned: false
                      logoutResponseSigned: false
                      wantMessagesSigned: false
                      wantAssertionsSigned: false
                      wantNameIdEncrypted: false
                      requestedAuthnContext: true
                      signMetadata: false
                      wantXMLValidation: true
                      signatureAlgorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
                      digestAlgorithm: 'http://www.w3.org/2001/04/xmlenc#sha256'
                  contactPerson:
                      technical:
                          givenName: '<YOUR_NAME>'
                          emailAddress: '<YOUR_EMAIL>'
                      support:
                          givenName: '<YOUR_NAME>'
                          emailAddress: '<YOUR_EMAIL>'
                  organization:
                      en:
                          name: '<YOUR_COMPANY>'
                          displayname: '<YOUR_COMPANY>'
                          url: 'https://<YOUR_COMPANY_URL>'

The role mapping requires you to create roles in zitadel, and also add an Action to enrich the SAML response with those roles.

You can call these anything you want. In order to find out the $PROJECT_ID check the Resource Id under the project where you are creating those roles.

Add that as an Environment Variable to your deployment, so it gets interpolated into the final local.yaml file.

Simply having those is not enough, however, as they are not part of the SAML payload by default.

Go to Actions and create a new Action called setCustomAttribute.

The content of the function comes from example code in Zitadel (API Docs).

Now wire up that action to the Complement SAML Response flow.

Issues

SLO is currently broken when you Log out, so I have it commented out in the local.yaml. Please watch https://github.com/zitadel/zitadel/issues/11042 and https://github.com/kimai/kimai/issues/5683

Latest portainer (2.33.3 at the time of writing) has a bug where configs are not applied at all. I spent half a day trying to get that mystery solved. So I've rolled back to 2.33.2. Watch this issue for resolution https://github.com/portainer/portainer/issues/12909

Subscribe to Vasili's Blog

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe