User authentication
User authentication in Kittox is optional. If an application needs to authenticate users, an authenticator among a selection of predefined ones can be enabled, or a custom authentication can be developed.
In order to enable an authenticator you need to:
- Specify its name and settings in the config file.
- Include/use the relevant unit in your project.
Standard authenticators (Auth: parameter values):
| Name | Unit | Description |
|---|---|---|
| DB | Kitto.Auth.DB | Uses a database table of users and optionally hashed passwords |
| DBCrypt | Kitto.Auth.DB | Same user table as DB, but passwords are bcrypt-hashed in the PASSWORD_B_HASH column. |
| DBServer | Kitto.Auth.DBServer | Authenticates users as DB server users. Requires a Connection block that reads the typed credentials through the %Auth:...% macros — see Auth: DBServer. |
| OSDB | Kitto.Auth.OSDB | Uses Operating System authentication |
| TextFile | Kitto.Auth.TextFile | Uses a text file of users and optionally hashed passwords. See Auth: TextFile. |
| LDAP | Kitto.Auth.LDAP | Validates users with a simple bind against an LDAP directory (Active Directory or generic LDAP); no local user table. See Auth: LDAP. |
| JWT | Kitto.Auth.JWT | Wraps another authenticator, issues a self-contained signed JWT in an HttpOnly cookie that replaces the opaque session id. See JWT Authenticator for the full reference. |
Other authentication methods will be added. Plus, it's easy to tweak existing authenticators and create inherited modified versions.
By default, the Null authenticator is used, which does not require user authentication. It accepts every request as an authenticated anonymous session, and refuses password change, password reset and PIN/QR enrolment: there is no account for it to write to.
Startup checks
Two configuration mistakes used to produce a running application that authenticated nobody. Both now stop the application at startup, with the reason in the log:
| Configuration | What happens |
|---|---|
Auth: names a class that nobody registered — a typo, or the unit missing from UseKitto.pas | The application refuses to start, and the message lists the class ids actually registered. Only Null is registered by the core: DB, DBCrypt, TextFile, DBServer, OSDB, LDAP and JWT each need their unit in the project's UseKitto.pas. Previously the application started and served a blank page with nothing in the log. |
Auth: DBServer whose Connection block has fixed credentials | The application refuses to start. This authenticator validates a login by opening the connection with the supplied credentials, so a Connection with its own user name and password checks nothing and accepts everyone. See Auth: DBServer. |
The Login dialog
When authentication is enabled, a Kittox application displays a Login dialog at startup, unless all required credentials are specified by other means.
Credentials can vary depending on the authenticator, but usually they are UserName and Password, both of type String.
The other available means of specifying credentials, needed in special cases, are basically two:
- In the Config file, inside a Auth/Defaults subnode.
- In the URL, as standard URL parameters.
What credentials in the URL do, and do not, do
?UserName=…&Password=… on the application root authenticates the user — the identity is resolved and the home renders. But it does not go through the kx/login endpoint, so no token is issued: under Auth: JWT no session cookie is set, and under AccessControl: JWT there is no kx_acl claim, which is closed-world. The result is a home page where every view is denied and only Logout is visible — an apparently broken session rather than a working login. F5 also repeats the credentials, so the login page never returns.
The credentials are also exposed where a URL goes: the address bar, the browser history, and the Referer header sent to any external link on the page. They do not reach the log, which records the path without the query string.
Use it for what it is meant for — a deliberate deep link, a kiosk, an integration — and prefer Auth/Defaults when the goal is simply to pre-fill or preset credentials. Since 4.0.15 the application itself never produces such a URL: see How the form is submitted.
Default credentials can be specified also partially, in which case the Login dialog is still displayed with pre-filled fields.
Example (Config.yaml file):
Auth: DB
Defaults:
UserName: guest
Password:The passepartout (master password)
IsPassepartoutEnabled: True plus a PassepartoutPassword gives you a master password: it authenticates as any user name that exists, whatever that user's own password is. It is meant for technical intervention — entering as a specific user to reproduce what they see — and it is supported deliberately.
Auth: DB
IsPassepartoutEnabled: True
PassepartoutPassword: <a long, high-entropy secret>| Passepartout | |
|---|---|
| user exists, with a password | accepted |
| user exists, password column empty or NULL | accepted — the master password is the credential, not a blank one |
| user not in the table / not in the user list file | refused: it impersonates, it does not create |
user disabled (IS_ACTIVE = False, or a # in the user list file) | refused |
PassepartoutPassword left empty | ignored (see below) |
It is compared with the password as typed, never with its hash, on both DB and TextFile — so the value in Config.yaml is the master password in clear, not its MD5.
An enabled passepartout with no password configured is ignored
IsPassepartoutEnabled: True with an empty PassepartoutPassword used to compare two empty strings, so a blank password logged in as any existing user — the opposite of a master password. It is now ignored in that case, and the misconfiguration is logged the first time somebody hits it. Configuring a real value restores the full behaviour.
Two things the framework does not do for you
The value is read with Config.GetString, so macros are not expanded: today it can only be a literal in the configuration file. And a passepartout login sets an IsPassepartoutAuthentication flag in the auth data but nothing writes it to the log, so in the log an intervention is indistinguishable from the user's own login.
Database environment choice
When the application is configured with multiple databases under the Databases node, an Auth/DatabaseChoices setting lets the user pick which database to authenticate against from a drop-down on the login page. The chosen database becomes the active one for the entire session.
Auth: DB
DatabaseChoices: FireDAC_MSSQL, FireDAC_PostgreSQL, FireDAC_Firebird
...A listed name is shown only if that database is actually usable — its Databases/<Name> block is defined and the DB adapter it references (FD, ODAC, DBX, ADO, …) is compiled into the build (its EF.DB.* unit is in UseKitto.pas). Choices that fail either check are silently skipped, so you can permanently list an optional back-end (e.g. ODAC_Oracle) and it appears only once enabled. See the Login docs for details.
See the Environment / database choice section of the Login controller documentation for the full description (combo placement, default selection rules, persistence cookie, %Auth:DatabaseName% macro).
Default SQL queries
When Auth: DB is enabled, the framework uses a built-in set of SQL queries against the KITTO_USERS table. They are dialect-agnostic by design: each IS_ACTIVE and MUST_CHANGE_PASSWORD literal is written as the %DB.TRUE% / %DB.FALSE% macro, so the same query works whether those columns are declared as BIT (SQL Server), smallint (Firebird ≤ 2.5, Oracle), or native boolean (PostgreSQL, Firebird 3+).
Default queries (from Source/Kitto.Auth.DB.pas):
-- ReadUserCommandText
select USER_NAME, PASSWORD_HASH, EMAIL_ADDRESS, MUST_CHANGE_PASSWORD
from KITTO_USERS
where IS_ACTIVE = %DB.TRUE% and USER_NAME = :USER_NAME
-- SetPasswordCommandText
update KITTO_USERS
set PASSWORD_HASH = :PASSWORD_HASH, MUST_CHANGE_PASSWORD = %DB.FALSE%
where IS_ACTIVE = %DB.TRUE% and USER_NAME = :USER_NAME
-- ResetPasswordCommandText
update KITTO_USERS
set PASSWORD_HASH = :PASSWORD_HASH, MUST_CHANGE_PASSWORD = %DB.TRUE%
where IS_ACTIVE = %DB.TRUE% and EMAIL_ADDRESS = :EMAIL_ADDRESS AND USER_NAME = :USER_NAME
-- RegisterNewUserCommandText
insert into KITTO_USERS (USER_NAME, PASSWORD_HASH, IS_ACTIVE, MUST_CHANGE_PASSWORD, EMAIL_ADDRESS)
VALUES (:USER_NAME, :PASSWORD_HASH, %DB.TRUE%, %DB.TRUE%, :EMAIL_ADDRESS)The bcrypt-aware variants use the PASSWORD_B_HASH column and are enabled by choosing the dedicated DBCrypt authenticator (Auth: DBCrypt, optionally with a BCryptCostValue node), not by a flag on the DB authenticator.
DBCrypt requires PASSWORD_B_HASH in the read query
The authenticator recognises a bcrypt password by finding a non-empty PASSWORD_B_HASH in the user record, so an overridden ReadUserCommandText must select that column. When it is absent or empty the user is treated as a legacy one and validated against PASSWORD_HASH, and the first successful login re-hashes the password into PASSWORD_B_HASH (cost from BCryptCostValue).
Fixed in 4.0.15: the column was never actually read, so IsBCrypted was always False and no bcrypt password could be validated — Auth: DBCrypt did not work at all. If you had worked around this with a custom authenticator, you can now drop the workaround.
You can override any of these queries explicitly in Config.yaml under the Auth node — useful when you have a legacy user table with different column names. See Custom User Table and Custom User Columns for examples.
Changing a password
The ChangePassword view writes through the authenticator, so what it can do depends on which one is configured:
| Authenticator | Change from the application |
|---|---|
DB | yes — SetPasswordCommandText |
DBCrypt | yes — the new password is bcrypt-hashed before being stored |
OSDB | yes — it keeps no hash of its own, and the "must differ" check is skipped when no password is stored |
TextFile | no — the user list file is never rewritten |
LDAP | no — passwords live in the directory (see above) |
DBServer | no — the accounts are database-server users; changing one is a DBA job |
Null | no — there is no account |
JWT | whatever the Inner authenticator supports; the wrapper forwards SetPassword and IsPasswordMatching to it |
Which row applies is not a documentation convention: it is the authenticator's own answer to SupportsPasswordChange, and both the ChangePassword view and the kx/changepassword endpoint ask before writing. Where the answer is no the user gets an explicit refusal, and no password is accepted and silently discarded. Until 4.0.15 those authenticators inherited an empty SetPassword: the dialog reported success and nothing was written anywhere.
Writing your own authenticator
SupportsPasswordChange defaults to True, so an authenticator that overrides SetPassword needs no change. Override it to False when the credential lives somewhere you do not write to — an identity provider, a directory, the operating system. A wrapper (see TKAuthenticatorDecorator) must forward it to its inner authenticator.
Note that MUST_CHANGE_PASSWORD is read by ReadUserCommandText and cleared by SetPasswordCommandText: a user whose flag is set is sent to the change-password screen as the home page, and gets in only once the password has been changed. ResetPasswordCommandText sets the flag again, which is what makes a reset-by-mail temporary password temporary.
Imposed steps are enforced server-side
A user who must still change the password, or accept the privacy terms, is authenticated: only the home page used to act on the two flags, by rendering the imposed dialog instead of the normal home view. Since 4.0.15 the authorization filter enforces them on every request, so a client that skips the home page does not skip the step: while MustChangePassword or MustConfirmAccess is pending, any endpoint not belonging to the required view answers 404, and the reason is logged.
| Pending flag | Required view name |
|---|---|
MustChangePassword | ChangePassword |
MustConfirmAccess | ConfirmAccess |
The names are matched literally against the view the request resolved to, so the view that renders the dialog must be named exactly like that. Exempt from the gate: the application root (it is what renders the dialog), endpoints marked as anonymous, views declared public, and every endpoint scoped to the required view itself — so the dialog can load and save. If the flag is raised and the application declares no view with that name, the request fails with a message that says which view is missing, instead of an empty page.
Password strength: the ValidatePassword node
Strength rules are enforced by the authenticator, inside SetPassword. DBCrypt reads them from a ValidatePassword node of its own configuration, and an application authenticator can do the same:
Auth: DBCrypt
ValidatePassword:
RegEx: '^(?=.*[A-Za-z])(?=.*\d)[ -~]{8,63}$'
Message: At least 8 characters, with letters and digitsRegEx defaults to ^[ -~]{8,63}$ — any printable character, from 8 to 63 — and Message is what the user sees when the password is rejected. The same expression also constrains the passwords produced by GenerateRandomPassword, so a temporary password sent by mail always satisfies the rules the user will be held to.
Do not look the node up by absolute path
ValidatePassword belongs to the authenticator, not to Auth. Under Auth: JWT it sits under Auth/Inner, so application code must read it through TKConfig.Instance.Authenticator.EffectiveConfigNode — see Configuration keys move under Auth/Inner.
PIN login (LoginType: PIN)
Auth: DB (and DBCrypt) can replace the password with a six-digit time-based one-time code (TOTP, the kind produced by Google Authenticator, Microsoft Authenticator, FreeOTP…):
Auth: DB
LoginType: PINThe login form still asks for a user name and a second field, but the value typed there is validated as a numeric TOTP code instead of the password: with LoginType: PIN the password is not checked at all. The code is verified against a shared secret stored in the user record, in a SECRET_CODE column, which your ReadUserCommandText must select:
select USER_NAME, PASSWORD_HASH, EMAIL_ADDRESS, MUST_CHANGE_PASSWORD, SECRET_CODE
from KITTO_USERS
where IS_ACTIVE = %DB.TRUE% and USER_NAME = :USER_NAMEFill SECRET_CODE with a random per-user value (base32, the alphabet authenticator apps expect), generated at enrolment and never derived from anything public. QRGenerate produces the enrolment QR code (otpauth://totp/<AppTitle>?secret=<SECRET_CODE>) from that same column, so the app and the server share exactly one secret per user.
Both login and enrolment refuse without a per-user secret
If the query does not select SECRET_CODE, or the column is empty for that user, the PIN login is refused and the QR code is not generated, with the reason in the log at high level.
Until 4.0.15 the secret fell back to base32(UPPERCASE(user name)). Since the user name is public and this branch replaces the password check, anyone who knew a user name could compute a valid code and log in as that user with no credential at all. There is no compatible fallback for that behaviour: enrol the users again with real random secrets.
ValidateTOPT accepts a ±window around the current 30-second step (default 4 → about ±2 minutes), which tolerates clock drift between the server and the phone.
Auth: DBServer
Auth: DBServer has no user table of its own: it authenticates a login by opening a database connection with the credentials the user typed. The accounts are the DBMS's own accounts. It follows that the Connection block must read those credentials, through the %Auth:UserName% / %Auth:Password% macros:
Auth: DBServer
DatabaseName: MyDB # optional; defaults to the default database
Databases:
MyDB:
Connection:
DriverID: MSSQL
Server: localhost
Database: MyDatabase
User_Name: '%Auth:UserName%' # required
Password: '%Auth:Password%' # requiredA Connection with fixed credentials authenticates everyone
If the Connection block carries its own user name and password, opening it succeeds no matter what the user typed — so every login is accepted, including one with a wrong password. The authenticator now checks the block at startup and refuses to start unless at least one of its values contains %Auth:, logging the reason. Before 4.0.15 it started and accepted anyone.
Password change, password reset and PIN/QR enrolment are not supported: altering a database-server account is a DBA operation, not something the application does.
Returning to the home page
Loading the application root ends the current session: the user is logged out and gets the login page. This is deliberate, and it is what Kitto1 did — a full page load starts a session from scratch, so pressing F5 on the root takes the user back to the login rather than silently resuming.
This matters when the application itself wants to bring an already authenticated user back to the home page: after login, after a language change, or after an operation such as a privacy confirmation. Do not redirect to the root by hand — call:
TKWebApplication.Current.ReloadOrDisplayHomeView;It marks the redirect as an application-issued reload (the ReloadingHome session flag), so the root serves the home page instead of logging the user out, and it applies a pending language change on the way. A rule that redirects to the root any other way will log the user out.
Reloading from the client
The same rule applies to a reload started in the browser, and there the flag cannot be raised in advance: by the time the reload reaches the root there is no earlier request to raise it in. A bare window.location.reload() on the home is therefore indistinguishable from a fresh page load, and signs the user out.
Client code that reloads to recover — not to end the session — must announce itself first:
kxReloadHome(); // defined in kxgrid.jsIt POSTs to kx/reloadhome, which raises the flag, and only then reloads. The framework uses it for the [Reset] and [Retry] buttons of the standard error dialog on both channels; use it in application JavaScript for the same reason. The endpoint raises the flag only for an already authenticated session, so it grants nothing by itself, and the flag is one-shot: a user pressing F5 afterwards still signs out, JWT cookie included.
| You want | Use |
|---|---|
| Bring an authenticated user back to the home (server side) | TKWebApplication.Current.ReloadOrDisplayHomeView |
| Reload the page from JavaScript, keeping the session | kxReloadHome() |
| End the session | TKWebApplication.Current.Logout, or just let the reload hit the root |
Full page loads, not partial updates
Both the login and this reload go through a complete page load, because the home page is rebuilt for the authenticated user — menus, theme and language included. Inside the application, ordinary navigation swaps content via HTMX and never touches the root.
No login with an empty password
A password that is empty on either side of the comparison is refused, and this is enforced wherever the password is the credential being checked:
| Authenticator | An empty typed password | A stored password that is empty or NULL |
|---|---|---|
DB, DBCrypt | refused | refused — the account cannot be entered at all |
TextFile | refused | refused (see Auth: TextFile) |
DBServer | refused before the connection is opened | not applicable — the account lives in the DBMS |
LDAP | refused before the bind | not applicable — the account lives in the directory |
OSDB, authenticating the OS user | accepted — see below | irrelevant: the password is not the credential |
OSDB, when the OS user is not an application user | refused | refused |
Null | there is no authentication to speak of | — |
The rule is about the password, not about the account: a passepartout login is unaffected, because what the operator types is the master password, not a blank one. See The passepartout.
Fixed in 4.0.15 — a blank password entered a passwordless account
The comparison was a plain string equality and GetStringHash('') returns '' (not the MD5 of an empty string), so an active user row whose password column was empty or NULL was matched by an empty typed password. Under DBCrypt the legacy branch went further: it accepted the login and stored a bcrypt hash of the empty password, so the account stayed enterable with a blank one for good.
Rows like that are not exotic — an application form that creates users without a password field produces them, and this documentation itself describes the state ("when no password is stored"). Such an account can now no longer be logged into: its owner gets in through the password reset instead.
Why OSDB is different, and how your own authenticator declares it
Auth: OSDB authenticates the operating-system user: the OS has already verified the identity, the password field carries nothing by design, and refusing an empty password there would break a correct configuration. The distinction is therefore not "is the password empty" but "is the password the credential this authenticator verifies", and it is a method:
/// True when the credential this authenticator verifies IS the password stored
/// in the user record. Default True.
function IsPasswordTheStoredCredential: Boolean; virtual;TKOSDBAuthenticator returns not IsUsingSystemUserName, so the exemption applies only while it is authenticating the OS user; as soon as the OS user is not an application user it behaves like Auth: DB and the refusal applies again. An authenticator of your own that delegates the check elsewhere — an identity provider, a token, a device — overrides it to False; one that compares a stored password leaves it alone.
The password-reset flow
This is how a user with no usable password gets back in, and it is unaffected by the rules above: it does not go through authentication at all.
- The user asks for a reset from the login page, giving user name + e-mail address.
ResetPasswordCommandTextwrites a freshly generated temporary password on the row that matches both, and raisesMUST_CHANGE_PASSWORD. A wrong e-mail address fails with user name and email address not found.- The application mails the temporary password to the user, by overriding
AfterResetPassword— the framework writes the password, it does not send mail. - The user logs in with it and, the flag being set, lands on the imposed change-password screen and cannot go anywhere else until the password has been changed.
The temporary password comes from GenerateRandomPassword: 8 characters over [2-9A-Z] for DB (GetRandomString(8, '01') — the second argument excludes 0 and 1, it is not the alphabet), and 8 alphanumerics plus a special character for DBCrypt, re-generated until it satisfies the ValidatePassword expression, so a temporary password always satisfies the rules the user will then be held to.
Auth: TextFile
Auth: TextFile (unit Kitto.Auth.TextFile) reads a plain text file of user name=password lines. The file is re-read at every authentication, so a change takes effect without a restart.
Auth: TextFile
FileName: '%HOME_PATH%FileAuthenticator.txt' # this is the default
IsClearPassword: False # False = the file holds MD5 hashes
IsPassepartoutEnabled: False
PassepartoutPassword: ''# a line starting with # is ignored, which is how a user is disabled
alice=e52d98c459819a11775936d8dfbb7929
bob=e54cfb3714f76cedd4b27889e1f6a174
#carol=ee41750a2cd87a6fddd89d8760a5345eWith IsClearPassword: False (the default) the file holds the MD5 hash of each password; with True it holds the password itself. The user name is matched case-insensitively; the password is not.
Rules the authenticator enforces (the same ones Auth: DB applies to its user table):
| Situation | Result |
|---|---|
| Empty password | refused before any comparison |
| User name not in the file | refused |
Line with nothing after the = | no password can authenticate it, and the broken user list is logged at high level (a passepartout still can — the user is listed) |
User disabled with a leading # | refused (the line is not loaded at all) |
PassepartoutPassword matches | accepted for any user listed in the file, including one whose line carries no password — see The passepartout |
IsPassepartoutEnabled: True with an empty PassepartoutPassword | the passepartout is ignored, not matched |
Fixed in 4.0.15 — an empty password logged anybody in
GetStringHash('') returns '' (not the MD5 of an empty string), and TStrings.Values[] returns '' for a name that is not in the list. So an empty password compared two empty strings and succeeded, in both IsClearPassword modes, for any user name at all: one absent from the file, one made of spaces, one whose line had nothing after the =, and — worst — one disabled with a leading #, since the comment line is stripped and the user becomes "absent", which was precisely the condition that opened the door. The required attribute on the password box is client-side only and does not stop a scripted request.
Also changed, and the only behaviour change for a working configuration: with IsClearPassword: False the passepartout used to be compared against the hash of the typed password, so PassepartoutPassword had to hold an MD5. It is now compared with the password as typed, which is what Auth: DB has always done — put the master password in clear.
Password change, password reset and PIN/QR enrolment are not supported: the user list is read-only for the application (see Changing a password).
Auth: LDAP
Auth: LDAP (unit Kitto.Auth.LDAP) authenticates a user with a simple bind against an LDAP directory using the native Windows LDAP API (wldap32.dll). It needs no local user table: the directory itself confirms that the user exists and that the password is valid. After a successful bind, when a SearchBase is configured, the account's display attributes (name, e-mail, optionally group membership) are read and stored into the auth data, so they are available through the %Auth:...% macros and to the access controller.
It requires only UserName + Password (like the other classic authenticators). Add Kitto.Auth.LDAP to your project's UseKitto.pas.
Active Directory
Users log in with the familiar DOMAIN\user form (or a UPN, user@domain). Set DefaultDomain so they can type just the user name:
Auth: LDAP
Host: dc01.corp.local # domain controller (host name or IP)
Port: 389 # 389 plain, 636 with UseSSL
UseSSL: False # True = LDAPS (recommended in production)
DefaultDomain: CORP # prepended to a bare user name -> CORP\user
SearchBase: DC=corp,DC=local # base DN for reading the user attributes
# SearchFilter: (sAMAccountName=%s) # this is the default
Attributes: # override only if your schema differs (AD defaults shown)
Email: mail
FirstName: givenName
LastName: sn
FullName: displayName
Groups: memberOfGeneric (non-AD) LDAP
For a directory that binds by distinguished name (OpenLDAP, public test servers, …), set BindDNTemplate so the user types only a short name; the full DN is built from it:
Auth: LDAP
Host: ldap.example.com
Port: 389
BindDNTemplate: uid=%s,dc=example,dc=com # the user types just "jsmith"
SearchBase: dc=example,dc=com
SearchFilter: (uid=%s)
Attributes:
Email: mail
FullName: cn
LastName: snThe KEmployee live demo uses exactly this setup against the public ldap.forumsys.com test directory.
Parameters
| Parameter | Description |
|---|---|
Host | LDAP server host name or IP (required). |
Port | TCP port. Default 389, or 636 when UseSSL: True. |
UseSSL | True opens an LDAPS connection. Recommended in production — a plain simple bind sends the password in clear over the wire. |
DefaultDomain | NetBIOS domain prepended to a bare user name (user → DOMAIN\user). Ignored when BindDNTemplate is set. |
BindDNTemplate | Template with a single %s placeholder that builds the full bind DN from a short user name, e.g. uid=%s,dc=example,dc=com. Takes precedence over the DOMAIN\user / DefaultDomain handling. |
SearchBase | Base DN used to read the user's attributes after the bind. Omit to authenticate only (no attribute lookup). |
SearchFilter | LDAP filter with one %s placeholder for the user name. Default (sAMAccountName=%s). |
Attributes/Email · FirstName · LastName · FullName · Groups | Directory attributes mapped into the auth data as EMAIL_ADDRESS, FIRST_NAME, LAST_NAME, FULL_NAME, MEMBER_OF. Defaults: mail, givenName, sn, displayName, memberOf. |
Passwords are managed by the directory, so changing a password from the application (SetPassword), ResetPassword and PIN/QR generation are not supported: the authenticator answers False to SupportsPasswordChange, so the ChangePassword view refuses with an explicit message instead of reporting success without writing anything (before 4.0.15 it did the latter, so the view had to be kept out of the menu by hand). An empty password is rejected before the bind (some servers would otherwise accept it as an anonymous bind). Because Auth: JWT can wrap any inner authenticator, Inner: LDAP gives you an AD/LDAP login inside a signed-cookie envelope — this is what KEmployee does. Deploying under IIS/Apache needs no special configuration: the bind is an outbound connection from the app to the directory.
Auth: JWT (signed-cookie envelope)
Auth: JWT is a wrapper authenticator: it delegates the actual credential check to an Inner authenticator (DB, TextFile, OSDB, custom — anything registered in TKAuthenticatorRegistry) and, on a successful login, signs a JWT carrying the user identity plus a few session-bound claims and writes it to a single HttpOnly cookie. Every subsequent request validates the cookie's signature and rehydrates the session from the verified claims, instead of relying on a server-side session id cookie.
Auth: JWT
Inner: DB
IsClearPassword: False
IsPassepartoutEnabled: True
PassepartoutPassword: password
.Defaults: { UserName: administrator, Password: password }
SigningAlgorithm: HS256 # HS256/384/512, RS*, ES*
SigningKey: env:KX_JWT_KEY_MYAPP # env:VAR | file:/path | inline
Issuer: MyAppX
Audience: kx-app
TokenLifetime: 3600 # seconds
SlidingThreshold: 600 # re-issue cookie when (exp - now) < this
ClockSkew: 60 # seconds tolerance
Claims:
IncludeRoles: True
IncludeDB: True
IncludeDisplayName: True
IncludeLanguage: True
# Note: kx_acl is auto-included when AccessControl: JWT — no flag hereCookies emitted under Auth: JWT
| Cookie | Set by | Purpose |
|---|---|---|
kx_token | server (login + sliding refresh) | The JWT itself: identity + claims, HttpOnly, Secure, SameSite=Lax, path-scoped on AppPath |
kx_sw | client JavaScript (Home/Templates/_Page.html) | Screen size for responsive HomeView selection (no relation to auth) |
The legacy session-id cookie named after AppName (e.g. MyAppX) and the multi-database kx_db cookie are not emitted under Auth: JWT: the JWT's sid and db claims carry the same information and live within the single signed envelope. After upgrading from a non-JWT build, the legacy cookies present in the browser are cleared on the next session-end / login POST.
Trade-off on the kx_db claim vs cookie
Under non-JWT auth, kx_db was a 30-day cookie that pre-selected the last picked database environment on the login form. Under Auth: JWT the db claim lives inside kx_token (typically 1-hour lifetime, slid every active request), so when the token finally expires the next login form pre-selects DefaultDatabaseName instead of the last picked one. During an active session there is no observable difference: the JWT slides on every request and the chosen database stays.
Implementation note: per-thread JWT context cache
TKJWTAuthenticator.AuthorizeRequest validates the cookie once per request and caches the parsed TKJWTContext for downstream consumers (TKJWTAccessController reads kx_acl from it). The cache is implemented as a unit-level TObjectDictionary<TThreadID, TKJWTContextHolder> (in Source/Kitto.Auth.JWT.pas) protected by a TCriticalSection, owned by the unit so its finalization deterministically frees the holders. A threadvar of record was rejected because the Delphi runtime does not release managed members (strings, dynamic arrays) of records when a worker thread exits, and the Indy thread pool keeps ~20 workers alive for the server lifetime.
Inner authenticator override pattern
All the SQL override keys documented above (ReadUserCommandText, SetPasswordCommandText, ResetPasswordCommandText, RegisterNewUserCommandText) are still read by the Inner authenticator, so put them under Auth/Inner (not directly under Auth):
Auth: JWT
Inner: DB
ReadUserCommandText: |
select USER_NAME, PASSWORD_HASH, ...
from MY_LEGACY_USERS where ...
SetPasswordCommandText: |
...
SigningAlgorithm: HS256
...The same applies to DatabaseChoices and any other key the Inner consumes: TKAuthenticator.EffectiveConfigNode is overridden in TKJWTAuthenticator to expose the Inner's config to callers, so the framework keeps reading the right keys without code changes.
