feat: automatic Kimai onboarding for OIDC logins - #23
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_identityentry inusers.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.pyis a two-line fallback in thematch is Nonebranch, becauseprovision()returns the same(slug, UserConfig)shape asget_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 readsusers_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/meand discarded if it resolves elsewhere — but that guards a wrong token, not a wrong match, which is why--provision-matchgates how far matching goes and the two name-based heuristics needfuzzy.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 FILEfor deployments that would rather not churn Kimai tokens on every deploy. That file holds tokens in plaintext, so it is written0600. Hand-writtenusers.jsonalways wins over a stored entry.The Kimai plugin
kimai-plugin/ApiTokenBundlesupplies 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 inProfileController::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-tokenvoter, so it grants nothing the Kimai UI would not: the caller needsapi-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
curlsequence in its README. Its assumptions were cross-checked against Kimai 2.65.0 (AccessTokenentity,AccessTokenRepository, theapi-tokenvoter, and byte-identical token generation).Incidental fixes
UsersConfig.load(allow_empty=True)and a softenedinitialize_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
tests/test_provisioning.pyhas 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 wasMaxor whose address wasmax@on another mail domain — one candidate each, so the ambiguity guard could not catch it.fuzzytier remains a documented heuristic: it will match a namesake who has no account of their own. It is opt-in for that reason./mcp/{slug}routes. Slugs are generated with the strengthusers.example.jsonrecommends, and the server warns at startup when auto-provisioning and legacy slugs are both active.ruffclean.🤖 Generated with Claude Code