# Kerberos single sign-on for the KodeMed Windows client For a hospital whose workstations are joined to an Active Directory domain and which wants its coders to open KodeMed from the HIS **without seeing a login form**: the Windows session the user already has is the sign-in. How it works, in one paragraph: the KodeMed client (`KodeMed.dll`, inside the HIS) opens the sign-in page in WebView2. When the Server publishes `ssoKerberos: true`, the client tells WebView2 that the Keycloak host may receive Kerberos tickets. Keycloak answers the first request with `WWW-Authenticate: Negotiate`, Windows asks the domain controller for a ticket for `HTTP/`, WebView2 sends it, Keycloak validates it with its **keytab**, looks the user up in AD through its LDAP provider, and the client receives its token. No form. If any link of that chain fails, the login form appears exactly as before. Kerberos is an additional way in, never the only one. > **Nothing is installed on the workstations and nothing changes in the HIS.** The client > reads the switch from the Server (`GET /api/v1/config`) every time it starts. The whole > setup is: one service account and one SPN in AD, two files on the KodeMed host, a few fields > in Keycloak, and one line in `.env`. --- ## Prerequisites | | Why | |---|---| | Workstations are **domain-joined** and users log on with their **domain account** | A local account has no Kerberos ticket to offer | | Keycloak is reached under its **FQDN**, never an IP | The ticket is requested for `HTTP/`. No SPN can name an IP address, and Windows does not even try | | That FQDN is an **A record, not a CNAME** (recommended) | Windows resolves a CNAME and asks for a ticket for the **canonical** name. The SPN you registered is for the alias, the KDC has no such principal, the browser falls back to NTLM, and Keycloak logs `Defective token detected`. If a CNAME cannot be avoided: register the SPN on the canonical name instead, **or** give the workstations the WebView2 policy `AdditionalBrowserArguments` with `--disable-auth-negotiate-cname-lookup` (see "Workstation policies" in section 4). The Edge policy `DisableAuthNegotiateCnameLookup` does **not** apply to WebView2 | | A **full host name** of at least three labels (`kodemed-sso.hospital.local`, never `hospital.local`) | The client and the Server refuse a shorter one: on a browser that matches its allow-list by suffix, `hospital.local` would cover every host of the domain. Same rule for the extra hosts | | **HTTPS** | Once a host is on WebView2's allow-list, Chromium answers Negotiate over plain http as well - so the client writes every entry as `https://` and the Server publishes `ssoKerberos: false` for an http sign-in URL. KodeMed serves Keycloak over https in every supported topology | | **Time is synchronised** (NTP) on the KodeMed host and the domain controllers | Kerberos rejects a ticket when the clocks differ by more than 5 minutes (`Clock skew too great`) | | The bundled Keycloak (or your own IdP) has an **LDAP provider** for this AD | Kerberos proves *who* the user is; Keycloak still needs the user in its realm to issue a token with roles | Which host is "the Keycloak host"? The one in the issuer URL — `OIDC_ISSUER_URI` in `.env`, `oauth2Url` in `GET /api/v1/config`. In the usual domain layout that is `sso.`; in single-domain mode (`--single-domain`) it is the one KodeMed host, with Keycloak under `/auth`. Below it is written `kodemed-sso.hospital.local`, and the AD realm `HOSPITAL.LOCAL`. > Behind a reverse proxy or an F5: nothing special, as long as the proxy passes the > `Authorization` request header and the `WWW-Authenticate` response header through > unchanged. Most do by default. The SPN is always the name **the workstation** uses. --- ## 1. Active Directory (a domain administrator) ### 1.1 The service account Create a plain user account, e.g. `svc-kodemed-kc`: password never expires, cannot change password, member of nothing special. It only carries the SPN. **Before anything else**, on its *Account* tab tick **"This account supports Kerberos AES 256 bit encryption"**. The flag decides which key `ktpass` writes; set it afterwards and the keytab holds the wrong key type, which reads as `Checksum failed` in Keycloak's log. ### 1.2 The SPN ```bat setspn -S HTTP/kodemed-sso.hospital.local HOSPITAL\svc-kodemed-kc setspn -Q HTTP/kodemed-sso.hospital.local ``` `-S` refuses a duplicate. The query must return **exactly one** account: an SPN registered on two accounts makes the KDC refuse to issue tickets for it, and every user silently falls back to the form. ### 1.3 The keytab ```bat ktpass /out keycloak.keytab ^ /princ HTTP/kodemed-sso.hospital.local@HOSPITAL.LOCAL ^ /mapuser HOSPITAL\svc-kodemed-kc ^ /pass * ^ /crypto AES256-SHA1 ^ /ptype KRB5_NT_PRINCIPAL ``` - `/pass *` prompts for the password instead of putting it on the command line. - **`ktpass` resets the account's password** to the one you type. Any older keytab for that account stops working at once. If the password is ever changed again, the keytab has to be exported again. - The realm part (`@HOSPITAL.LOCAL`) is the AD domain in **upper case**. - `ktpass` also sets the account's `userPrincipalName` to the principal. That is expected. The resulting `keycloak.keytab` is a **credential**: whoever holds it can impersonate the service. Transfer it like a password (not by mail) and delete the copy on the admin workstation afterwards. --- ## 2. The `kerberos/` directory on the KodeMed host The bundled Keycloak mounts a host **directory**, read-only, at `/opt/keycloak/conf/kerberos`. By default it is `kerberos/` in the install directory (`KC_KERBEROS_DIR` in `.env`); the deployment ZIP, `setup.sh` and `kodemed-update.sh` all create it. It is a directory on purpose: a missing single-file bind source is turned into an empty directory by Docker and refused by podman. If you point `KC_KERBEROS_DIR` elsewhere, give it a **dedicated** directory that holds nothing else: the mount carries the `:z` option, so on an SELinux host the engine **relabels** the whole directory for container use. Never point it at `/etc`, a home directory or any directory other software uses. Put exactly two files in it: **`keycloak.keytab`** — from step 1.3. **`krb5.conf`** — minimal: ```ini [libdefaults] default_realm = HOSPITAL.LOCAL dns_lookup_kdc = true dns_lookup_realm = false rdns = false ``` `dns_lookup_kdc` finds the domain controllers through the AD SRV records, which is right as long as the KodeMed host uses the AD DNS. If it does not, name them: ```ini [realms] HOSPITAL.LOCAL = { kdc = dc1.hospital.local kdc = dc2.hospital.local } ``` The JVM finds this file through `-Djava.security.krb5.conf=…` in Keycloak's `JAVA_OPTS_APPEND`, which the generated compose builds from `KC_KERBEROS_KRB5_CONF` (default `/opt/keycloak/conf/kerberos/krb5.conf`). You only change that key if you put the file elsewhere. Your own JVM options for Keycloak go in `KC_JAVA_OPTS_APPEND` (empty by default; e.g. `-Dsun.security.krb5.debug=true` while diagnosing): the compose puts them in front of the krb5.conf option. Keycloak runs as **uid 1000**: ```bash cd /opt/kodemed sudo chown -R 1000:1000 kerberos sudo chmod 0750 kerberos sudo chmod 0400 kerberos/keycloak.keytab sudo chmod 0640 kerberos/krb5.conf # rootless podman: podman unshare chown -R 1000:1000 kerberos ``` Then recreate Keycloak so it sees the files: `docker compose up -d kodemed-keycloak`. `kodemed-backup.sh` copies `kerberos/` into the backup and says the backup now contains a credential. With Kerberos switched on and the bundled Keycloak in the compose, a backup **without** a keytab carries a warning (the rest of the backup is still restorable; the keytab would have to be exported again). --- ## 3. Keycloak: the LDAP provider's Kerberos section Keycloak admin console → realm **kodemed** → **User federation** → your LDAP provider → section **Kerberos integration**: | Field | Value | |---|---| | Allow Kerberos authentication | **On** | | Kerberos realm | `HOSPITAL.LOCAL` | | Server principal | `HTTP/kodemed-sso.hospital.local@HOSPITAL.LOCAL` — exactly as in `ktpass /princ` | | Key tab | `/opt/keycloak/conf/kerberos/keycloak.keytab` (the path **inside** the container) | | Kerberos principal attribute | see below | | Debug | Off (turn on only while diagnosing) | | Use Kerberos for password authentication | Optional. On = the login form checks passwords against the KDC instead of an LDAP bind | **Kerberos principal attribute** decides how Keycloak finds the AD user for the principal in the ticket (`jdoe@HOSPITAL.LOCAL`): - **`userPrincipalName`** — Keycloak looks for a user whose UPN equals the principal. Right when your users' UPNs are `@`. - **Empty** — Keycloak takes the part before `@` and looks it up with the provider's *Username LDAP attribute*. That only works when that attribute is **`sAMAccountName`**. The trap: a provider whose username attribute is `cn` or `userPrincipalName`, or users whose UPN carries a different suffix (`j.doe@hospital.ch`), validate the ticket perfectly and are then *not found* — the user sees the form and nothing looks wrong. To check: on a workstation, `klist` shows the user's principal on its first line. Compare it with the user's `userPrincipalName` in AD and pick the option that matches. Saving the provider with *Allow Kerberos authentication* on sets the **Kerberos** step of the **browser** flow to **Alternative**. Verify it under **Authentication → Flows → browser**: the *Kerberos* execution must read *Alternative* — never *Required* (that would lock out every user whose ticket fails) and not *Disabled* (Kerberos would never be offered). If the users come from your own IdP (brokered, see `KodeMed_Identity_Provider_Guide.md`) rather than an LDAP provider, Kerberos is configured **in that IdP**, not here; only step 4 applies to KodeMed. Two more things then: list that IdP's host (e.g. the ADFS) in `KODEMED_SSO_NEGOTIATE_EXTRA_HOSTS`, and make Keycloak go straight to it - otherwise its own login page, with the IdP as a button, still appears. Either set the **Identity Provider Redirector** step of the **browser** flow to that IdP (*Authentication → Flows → browser → Identity Provider Redirector → ⚙ → Default Identity Provider*), or have the sign-in request carry `kc_idp_hint=`. --- ## 4. Switch it on In `.env`: ```bash KODEMED_SSO_KERBEROS=true ``` then `docker compose up -d kodemed-server` (an environment value is read when a container is created). The same switch is in admin **Settings → Windows sign-in (Kerberos)**; a value saved there wins over `.env` until *Reset to .env value*. `KODEMED_SSO_NEGOTIATE_EXTRA_HOSTS` stays empty in nearly every installation. It lists further hosts, comma separated, that the client may send tickets to — only for a sign-in that passes through another Kerberos-protected host (for example an ADFS in front of Keycloak). Full host names only (at least three labels). The list is published on the **unauthenticated** `GET /api/v1/config`, so anyone who can reach the Server can read it: list host names you are content to disclose. **Nothing on the workstations, nothing in the HIS.** The client reads `ssoKerberos` from the Server each time it starts and allows the Keycloak host (taken from the issuer) plus the extra hosts for Negotiate, each as `https://`. ### Workstation policies (only if you already use them) WebView2 does **not** apply Edge's browser policies: `AuthServerAllowlist`, `AuthNegotiateDelegateAllowlist` or `DisableAuthNegotiateCnameLookup` set for Edge by GPO change nothing in the KodeMed client. What does reach WebView2: - the policy **`AdditionalBrowserArguments`** under `HKLM\Software\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments` (or `HKCU\…`): a string value whose **name** is the host executable (e.g. `HIS.exe`) or `*` for every WebView2 application, and whose **data** is the arguments; - the environment variable **`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`**. Their content is **appended** to the arguments the application sets. So if the hospital already pushes its own `--auth-server-allowlist=…` through one of them, that list - not the client's - may be the one in force: include `https://` (and any extra host) in it, or Windows sign-in never starts on those PCs. The same mechanism carries `--disable-auth-negotiate-cname-lookup` when a CNAME cannot be avoided. ### Citrix / terminal servers On a terminal server every session reaches the Server from the **same** IP address, and `GET /api/v1/config` sits in the rate limiter's `auth` bucket (20 requests, then 10 a minute, per IP). When many users start the client in the same minute, the client retries a refused request once after 1.5 s (or the `Retry-After` the Server sends, at most 5 s) and otherwise shows the login form. For a host with many sessions raise the bucket in `.env`, e.g. `KODEMED_RATE_LIMIT_AUTH_CAPACITY=200` and `KODEMED_RATE_LIMIT_AUTH_REFILL=100`, then `docker compose up -d kodemed-server`. The same holds for the default bucket every other request uses (100 + 100 a minute per IP): size `KODEMED_RATE_LIMIT_CAPACITY` / `KODEMED_RATE_LIMIT_REFILL` for all sessions of the host. The same applies behind a load balancer that does not forward the client address - see `EXTERNAL-PROXY.md`, *Load balancers, NAT, RDS/Citrix* (#2164); `kodemed-doctor.sh` shows the effective limits and the recent 429s. --- ## 5. Verifying it 1. On the KodeMed host: `bash tools/kodemed-doctor.sh`. The section *Kerberos single sign-on* checks that the config endpoint publishes the switch, the sign-in host is https and a name, whether it is a CNAME, and that Keycloak can read the keytab and `krb5.conf`. 2. On a workstation, as a normal user: `klist purge`, then close and reopen the HIS and open KodeMed. It opens **without a form**. 3. `klist` now lists a ticket for `HTTP/kodemed-sso.hospital.local@HOSPITAL.LOCAL`. Opening `https://kodemed-sso.hospital.local/realms/kodemed/account` in Edge is a useful test of the **server side** only (SPN, keytab, Keycloak). Edge has its own allowlist (and its own policies, which WebView2 does not read), so a form there does not mean the client will fail, and a success there does not prove the client reads the switch. For detail, turn **Debug** on in the provider's Kerberos section and read `docker logs kodemed-keycloak` while a user signs in. Turn it off afterwards. --- ## When it does not work | Symptom | Cause | |---|---| | The form appears, and Keycloak logs nothing about Kerberos/SPNEGO | The client was not told: `ssoKerberos` is not `true` in `GET /api/v1/config` (Server not recreated; an older compose that does not declare the key — run `kodemed-update.sh`; the sign-in URL is http or its host has fewer than three labels; admin Settings switches it off), or a WebView2 `AdditionalBrowserArguments` policy / `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` carrying its own `--auth-server-allowlist` without the Keycloak host | | `Defective token detected` in Keycloak's log | The browser sent NTLM, not Kerberos: no SPN for that host, SPN on two accounts (`setspn -Q`), the host is a CNAME, an IP is used, or the PC/user is not in the domain | | `Checksum failed` / `Cannot find key of appropriate type` | Keytab and account disagree: the AES 256 flag was set after `ktpass`, or the password changed after it. Export the keytab again | | `Clock skew too great` | Time differs by more than 5 minutes between the KodeMed host and the domain controllers | | `Cannot locate default realm` / `krb5.conf` not found | Keycloak's `JAVA_OPTS_APPEND` does not carry `-Djava.security.krb5.conf` (a hand-written one replaced it - put your own options in `KC_JAVA_OPTS_APPEND` instead), or the file is not in `kerberos/` | | `Permission denied` on the keytab | Not readable by uid 1000 — see the `chown` above | | Ticket accepted, then the form anyway | The user is not found: *Kerberos principal attribute* does not match how your AD names users (section 3) | | Works on some PCs, not on others | Those PCs are not domain-joined, the user logged on with a local account, or they reach no domain controller (VPN) | | Works in Edge, not in the client | The switch is not published (first row), the client is older than #2047, or Edge works thanks to an Edge GPO (`AuthServerAllowlist`) that WebView2 does not read | Whatever the cause, users are never locked out: when Kerberos fails, Keycloak shows the login form. --- ## Kubernetes (Helm chart) `config.ssoKerberos: true` publishes the switch exactly like the `.env` key, and `config.ssoNegotiateExtraHosts` the extra hosts. The chart's own development Keycloak does **not** mount a keytab: on Kubernetes, Kerberos is done by the customer's IdP (or a Keycloak you configure yourself, with the keytab in a Secret mounted as a directory).