Skip to content

feat: automatic Kimai onboarding for OIDC logins - #23

Merged
glazperle merged 6 commits into
mainfrom
feat/auto-provisioning
Aug 13, 2026
Merged

feat: automatic Kimai onboarding for OIDC logins#23
glazperle merged 6 commits into
mainfrom
feat/auto-provisioning

Conversation

@glazperle

Copy link
Copy Markdown
Owner

Adds optional automatic Kimai onboarding on top of the OIDC login backend from #14, plus the Kimai-side plugin it needs.

Why

The OIDC backend federates the login, but the mapping to a Kimai account is still a hand-maintained oidc_identity entry in users.json — and each entry needs an API token an administrator first clicked together in Kimai's web UI. For a new colleague that is two manual steps before they can use the connector at all.

With --auto-provision, an identity that matches no configured user is resolved against Kimai's own user list and Kimai mints that user's personal API token on the spot. After that, signing in with the identity provider is the only step a user ever performs.

Design

Off by default and strictly additive. Every failure mode — no match, ambiguous match, plugin missing, admin token without permission, Kimai unreachable — answers with the same generic "not authorized" page the OIDC callback already returned, with the reason server-side in the log only. Enabling it cannot change behaviour for a deployment that works today.

One hook point. The whole wiring in oauth.py is a two-line fallback in the match is None branch, because provision() returns the same (slug, UserConfig) shape as get_user_by_oidc_identity(). Everything downstream — _issue_auth_code, /token, subject, _ensure_session — is untouched, and a runtime slug is live on the next request because _ensure_session() already reads users_config.users[slug] per request.

Matching refuses to guess. Rules run strongest-first and stop at the first that matches; a rule hitting more than one user aborts with "ambiguous" rather than falling through to a weaker one, because a wrong match hands one employee another employee's token. The minted token is verified against /api/users/me and discarded if it resolves elsewhere — but that guards a wrong token, not a wrong match, which is why --provision-match gates how far matching goes and the two name-based heuristics need fuzzy.

Persistence follows the OAuth client store. In-memory by default (access and refresh tokens are too, so a restart already means a silent SSO redirect); opt-in --provision-store FILE for deployments that would rather not churn Kimai tokens on every deploy. That file holds tokens in plaintext, so it is written 0600. Hand-written users.json always wins over a stored entry.

The Kimai plugin

kimai-plugin/ApiTokenBundle supplies the endpoint Kimai lacks: core Kimai can only delete access tokens through the API (DELETE /users/api-token/{id}); creating one is a web-form action in ProfileController::createAccessToken. The alternative would have been driving an admin web session through that HTML form — CSRF token, throttling, 2FA and all.

The bundle reuses Kimai's own api-token voter, so it grants nothing the Kimai UI would not: the caller needs api-token_other_profile, which only ROLE_SUPER_ADMIN holds by default. Requires Kimai 2.65+. It is part of neither the wheel nor the Docker image.

It has no automated tests, and this repository's CI cannot give it any — there is no PHP toolchain or Kimai checkout here. The Python side is covered; the PHP side is verified by the curl sequence in its README. Its assumptions were cross-checked against Kimai 2.65.0 (AccessToken entity, AccessTokenRepository, the api-token voter, and byte-identical token generation).

Incidental fixes

UsersConfig.load(allow_empty=True) and a softened initialize_users() check. Without them a provisioning-only deployment — the actual selling point — could not boot: both loaders and the session init insisted on at least one user existing before anybody had signed in.

Notes for review

  • The security-critical question is the matching. tests/test_provisioning.py has one test per rule plus the ambiguity cases; the last commit closes a collision found in review, where a single-given-name address (max@corp.example) matched a colleague whose alias was Max or whose address was max@ on another mail domain — one candidate each, so the ambiguity guard could not catch it.
  • The fuzzy tier remains a documented heuristic: it will match a namesake who has no account of their own. It is opt-in for that reason.
  • A provisioned slug is a credential on the deprecated /mcp/{slug} routes. Slugs are generated with the strength users.example.json recommends, and the server warns at startup when auto-provisioning and legacy slugs are both active.
  • 382 tests pass, ruff clean.

🤖 Generated with Claude Code

glazperle and others added 6 commits August 13, 2026 09:08
Core Kimai can only DELETE an access token; creating one is a web-form
action in ProfileController, so there is no API for it. The ApiTokenBundle
plugin added in the following commit supplies POST/GET
/api/users/{id}/api-token, and these two methods are its client side.

create_api_token() returns the token value, which Kimai renders exactly
once - hence the dedicated model rather than a raw dict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-token

The endpoint the previous commit calls does not exist in Kimai. Creating an
access token is a web-form action (ProfileController::createAccessToken), so
the only alternative for automated onboarding would be driving an admin web
session through that HTML form - CSRF token, throttling, 2FA and all.

The bundle reuses Kimai's own `api-token` voter, i.e. it grants nothing the
Kimai UI would not: the caller needs `api-token_other_profile`, which only
ROLE_SUPER_ADMIN holds by default. Requires Kimai 2.65+.

It is not part of the Python package or the Docker image; it is copied into
the Kimai host's var/plugins/. See kimai-plugin/ApiTokenBundle/README.md,
which also states plainly that this repository's CI cannot test PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Until now every user had to be declared in users.json before they could
sign in, together with an API token an administrator first created by hand
in Kimai's web UI. With --auto-provision the server resolves a verified
OIDC identity against Kimai's user list and has Kimai mint that user's own
token at first sign-in, so signing in with the IdP is all a user ever does.

Off by default: it needs a non-core Kimai plugin and a ROLE_SUPER_ADMIN
token resident in server memory. Nobody should acquire either by upgrading.
Every failure mode - no match, ambiguous match, plugin missing, permission
missing, Kimai unreachable - answers with the same generic 403 the callback
already returned, so enabling the feature cannot regress a deployment.

Matching is deliberately strict. Rules run strongest first, and a rule
matching more than one user aborts with "ambiguous" instead of falling
through to a weaker one: a wrong match hands one employee another
employee's token. The token verification is not a substitute for that - it
only proves the token belongs to the user we already picked - which is why
the two name-based heuristics need --provision-match fuzzy. They were built
against a directory whose shape was known, which no upstream deployment is.

Persistence follows the OAuth client store: in-memory by default (access
and refresh tokens are too, so a restart already means a silent SSO
redirect), opt-in --provision-store for deployments that would rather not
churn Kimai tokens on every deploy. That file holds tokens in plaintext, so
it is written 0600.

Also lifts two assumptions that made "SSO and nothing else" unbootable:
UsersConfig.load(allow_empty=) and initialize_users() no longer insist on a
user existing before the first login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README gets an "Automatic onboarding (optional)" section under the OIDC
backend, the seven new flags in the CLI table, and the match-mode table
with the warning that fuzzy matching is a heuristic. .env.server.example
and docker-compose.yml grow commented blocks; CLAUDE.md gets the
architecture entry and the users.json note that provisioned users are
runtime-only without --provision-store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The normalized rule compared an address local part against usernames,
display names and the local part of a possibly different mail domain. So
"max@corp.example" matched a colleague whose Kimai alias is "Max", or whose
address is "max@partner.example" - and since each produces exactly one
candidate, the ambiguity guard never fired. Provisioning then minted that
colleague's token and bound it to the newcomer's identity, which is the one
outcome the whole module is built to prevent. This was reachable in the
default match mode.

The folded comparison now requires the address to decompose into at least
two name parts. That keeps the case the rule exists for (anna.vondorf@ vs.
the alias "Anna von Dorf") and drops the class that collides. Single-token
addresses still reach the exact rules above it and the fuzzy tier below.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects a review of the fork this was extracted from surfaced, both of
which apply here unchanged.

The provisioned-user store never fsynced before the rename, so the rename
could reach the disk before the data and a host crash would leave a correctly
named but truncated file - exactly what the temp file exists to prevent.

The plugin cast replaceExisting with (bool), and the string "false" casts to
true. A client sending {"replaceExisting": "false"} got the opposite of what
it asked for and had its existing token deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@glazperle
glazperle merged commit 77257b5 into main Aug 13, 2026
6 checks passed
@glazperle
glazperle deleted the feat/auto-provisioning branch August 13, 2026 09:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant