Hi everyone,
I’ve been working on Kemal Identity for a while, an authentication shard for Kemal Web Framework, and I’m getting close to freezing its API for 1.0. I’d rather hear what’s wrong with it before that than after, so I’m posting to ask for opinions instead of waiting until it’s too late to change anything.
It’s built primarily for Kemal, since that’s what I use. What it does: server-side opaque sessions with real revocation, password login with bcrypt, CSRF, API tokens with scopes, optional JWT validation, TOTP, OIDC sign-in, and optional RBAC. It doesn’t do registration screens, user profiles or an OAuth2 server; those stay in your application and connect through repository contracts.
The core itself knows nothing about Kemal, though. The Kemal part is a separate adapter that adds env.auth and the middleware, and the core never requires it. I checked that the annoying way instead of trusting my own layering: a test app built on plain HTTP::Server links 189 KemalIdentity symbols and zero Kemal:: ones according to nm. So if anyone ends up liking this, adapters for other frameworks are just adapters, not a rewrite. I’m probably not going to write an Amber or Lucky one speculatively, but I kept the door open on purpose.
On storage there are Postgres and SQLite adapters right now, both against crystal-db directly. Adapters for ORMs like Avram or Granite are on my list next, since plenty of apps already have their models there and shouldn’t have to keep a second set.
KemalIdentity.configure(
accounts: KemalIdentity::Postgres::AccountRepository.new(db),
sessions: KemalIdentity::Postgres::SessionRepository.new(db),
csrf: KemalIdentity::CSRFConfig.new(secret: ENV["CSRF_SECRET"]),
)
use KemalIdentity::Kemal::ErrorHandler.new(login_path: "/login")
use KemalIdentity::Kemal::AuthenticationHandler.new
use KemalIdentity::Kemal::CSRFHandler.new
get "/dashboard" do |env|
principal = env.auth.require!
"Signed in as #{principal.subject}"
end
Most of my design time went into making it work for two different situations. One is a new app, where you take the defaults and go. The other is an app that already authenticates people and wants to move over without a flag day, which I think is the harder and more common case. For that: your existing users table stays where it is and you implement a five-method contract over it, old password hashes are verified by a LegacyVerifier you write and quietly rehashed to bcrypt on the next login, and a handler can adopt the session cookie your old system already set so nobody gets logged out during the switch. Whether that’s actually enough for a real migration is one of the things I’d most like to hear about, since I’ve only tried it on apps I invented.
Why I’m asking instead of just calling it done: I wrote a list of 50 things an auth library either handles or doesn’t, and I’ve been going through them one at a time from a separate project that depends on the shard the way a normal user would. I’m 27 in, and it keeps finding things I was sure were fine. Two examples, both now fixed:
- If your only bearer credential was one you wrote yourself, you silently lost the
WWW-Authenticatechallenge and the CSRF exemption for token-only requests, because both looked at a field you had no way to fill. - Asking for a password reset on an account with no password would send a link, and using it created a password. So a service account whose login is a team alias could be logged into by anyone on that alias.
Neither of those came up while I was reading my own code. That’s mostly why I don’t want to freeze the contracts on my own judgement.
The three I’m least sure about, if you have time for any of them:
- That five-method
AccountRepository. If you tried it against your real schema, what would break? I’ve done it once, against a UUID-keyed table with soft deletes, and one schema isn’t much evidence. Authz::Authorizableis two methods,authz_typeandauthz_id. I want to freeze it there, because anything more becomes something every domain object has to implement. Is that enough to put an external policy engine behind it?RequestAuthenticatoris one method,authenticate(credential : String?) : Outcome. For credentials that need the whole request (DPoP, mTLS) I planned to add a second overload with a default later. Does that work, or does the request need to be there from the start?
Anything else you notice is welcome too, including “why does this exist”. There are six examples in the repo if you’d rather read code than docs, and the notes on what the 50 scenarios have found so far are in blueprints/.
It’s at 0.9.0, Crystal 1.12+. Thanks for reading.