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