module

KemalIdentity

Authentication for Crystal web applications.

The core is framework-agnostic: nothing under src/kemal_identity/core knows that HTTP::Server::Context exists. Require kemal_identity/kemal for the Kemal adapter.

Constants

Log = ::Log.for("kemal_identity")

The audit trail.

docs/02-security-model.md lists what must reach a structured log — login success and failure with reason, logout, session rotation, bulk revocation, rate-limit denials, replay detection — and, more importantly, what must never: passwords, raw tokens of any kind, session cookies, Authorization headers, password digests, Set-Cookie values.

Emitting through a named source rather than puts is what lets an application route these somewhere durable, filter them by severity, or ship them to a SIEM without parsing prose. src/CLAUDE.md bans puts for exactly this reason.

The login is deliberately absent

Events carry subject — the account id — and never the login that was typed. An email address in a log file is a disclosure that outlives the request, gets copied into aggregators, and is read by people who never authenticated to anything. The account id identifies the account for anyone who can already query the database, which is the audience an audit trail is for.

The cost is that a failed attempt against an unknown login records no identifier at all, since there is no account to name. Detecting credential stuffing is the rate limiter's job (RateLimiter, v0.1 step 8), which keys on the login without writing it down.

VERSION = "0.12.2"

Class methods

app

The configured application.

Raises ConfigurationError rather than returning nil: reaching this unconfigured means a handler is about to authenticate a request against nothing, and that must be a loud startup-shaped failure rather than a nil check at every call site.

Source
app=(app : Application) : Application

Installs an already-built application. Useful when the application object is assembled elsewhere, and in specs.

Source
configured?

Whether an application has been installed. For a handler that wants to fail helpfully.

Source
event_bridge

The bridge the last event_sink= installed, if any.

Source
event_sink=(sink : SecurityEventSink) : EventBridge

Sends this shard's security events to sink, in addition to wherever they already go.

KemalIdentity.event_sink = SiemSink.new

Binds an EventBridge to the kemal_identity source at :debug, so the sink sees every event the catalogue in README.md lists rather than only the ones a severity filter let through — a SIEM decides what is interesting, and authentication.succeeded at info is as much a security event as a failure.

This calls ::Log.setup_from_env semantics on one source only, leaving whatever else the application configured alone: ::Log.builder.bind adds a backend rather than replacing the configuration. An application that has not configured Log at all still gets its own default behaviour for everything outside kemal_identity.

Call this after your own Log setup, and check it

::Log.setup, ::Log.setup_from_env and anything that calls them replace the whole configuration, binding included. So an application that wires a sink and then configures its logging has no sink any more, with nothing raised and nothing logged: the feed OPS-02 exists to keep alive goes quiet in the one way #failures cannot show, because no event ever reaches the bridge to fail.

Measured, from a consumer project: a sink bound at the top of a spec file received zero events, because Crystal's spec runner configures Log after the file loads. The same code in a plain program received everything. blueprints/0025, OPS-03.

event_sink_delivering? is the check, and a boot that depends on the trail should assert it.

Returns the bridge, so #failures is reachable for an alarm.

Source
event_sink_delivering?(yields : Int32 = 1000) : Bool

Whether events are actually reaching the sink right now.

Emits one EventBridge::PROBE event and waits for it to arrive, up to within. Answers false when a later ::Log.setup dropped the binding, which is the failure this exists for and which nothing else can see: an unbound bridge is not a failing bridge, so #failures stays at zero and the trail is simply empty.

Log.setup_from_env
KemalIdentity.event_sink = SiemSink.new

unless KemalIdentity.event_sink_delivering?
  abort "the security event sink is not receiving events; check Log setup order"
end

Delivery is asynchronous — EventBridge is an :async backend so a slow SIEM cannot sit on a request fiber — so this hands control to the dispatcher fiber rather than blocking. The budget is a number of yields rather than a duration on purpose: it needs no clock (this shard reads time only through an injected Clock), it cannot hang, and what is being waited for is a fiber handoff rather than an interval. Measured: nothing had arrived after ten yields and everything after fifty, so the default is twenty times what it took.

The sink sees one extra event per call, by design.

Source

Nested types