Howdy Auth
howdy_auth is an optional package that signs people in to a Howdy app. It
gives you:
- sign-in by a token sent to the visitor's email address, with no password to keep
- optional passwords alongside the email tokens
- social sign-in with Google, Apple, GitHub, Facebook and Microsoft Entra
- passkeys and multi-factor authentication
- enterprise single sign-on over OpenID Connect and SAML
- a guard that hands your handlers the signed-in user
- JSON endpoints, minimal starter pages, and the same operations as plain functions for when you bring your own pages
- groups for workspaces and tenants, and fields for small facts about a user
- separate roles and permissions
It stores everything in your app's own database, through the gloo Repo you
already have, using Howdy Database. SQLite and
PostgreSQL both work. Your app owns the connection and supplies the function
that sends email; the package never opens a database or talks to a mail
server itself.
An account is identified by its email address. There is no username login built in, though a username fits well as a field: see Username and password sign-in. SCIM, invitations by email and a hosted management dashboard are not implemented.
Install
Add the packages to gleam.toml, next to howdy:
howdy_auth = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.0.0", path = "auth" }
howdy_database = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.0.0", path = "database" }
howdy_database provides the howdy/migration module you run the auth
migrations with. Then download them, and build the native library that
hashes passwords:
gleam deps download
make -C build/packages/jargon/c_src
The second command needs make, a C compiler and the Erlang headers. Run it
again after gleam clean, after deleting build, and on each machine you
build on. Until a corrected release of the jargon package is published,
howdy_auth carries a patched copy of it, which is why this step is yours
to run. auth.with_passwords hashes a password as the app starts, so a
forgotten build shows up at startup and not at the first sign-in.
Create the tables
The package brings its own migrations. Run them before the app starts, with the same Repo your app uses:
import howdy/auth
import howdy/authorization
import howdy/migration
pub fn migrate(db) {
migration.run(db, [auth.schema(), authorization.schema()])
}
Leave out authorization.schema() if you do not need roles. The tables are
named howdy_auth_* and howdy_authz_*, and the package checks at startup
that nobody has changed them by hand.
Configure
Build one Auth value when the app starts, and share it:
import howdy/auth
pub fn identity(db, send_email) {
let assert Ok(identity) =
auth.new(
repo: db,
origin: "https://app.example.com",
deliver: send_email,
)
auth.allow_registration(identity)
}
-
repois your database. With SQLite, auth and your own code can share it, as long as your code goes throughhowdy/database. If your code callsgloo/repodirectly while requests are being served, give auth a second Repo of its own, opened on the same file. -
originis the public address of your site: scheme, host and optional port. It must behttps, except forhttp://localhost,http://127.0.0.1andhttp://[::1]while you develop. Auth never trusts thehostheader to work this out. -
deliversends an email. See Sending the email. An app that only uses providers can useauth.new_without_emailinstead.
Everything else is opt-in, and returns a new Auth:
-
auth.allow_registration(identity)lets visitors create accounts. Without it, only provisioned users exist. -
auth.with_passwords(identity)turns on passwords. -
auth.with_provider(identity, provider)adds a social sign-in provider. -
auth.with_passkeys(identity, name)turns on passkeys. -
auth.with_mfa(identity, config)turns on second factors. -
auth.with_sso(identity, config)turns on single sign-on connections. -
auth.with_policy(identity, policy)changes lifetimes and limits. -
auth.with_multi_session(identity, max:)lets one browser stay signed in to several accounts. -
auth.with_groups(identity, mode)turns on groups. -
auth.with_session_store(identity, store)keeps sessions somewhere other than the database. -
auth.with_account_deletionandauth.with_email_change_approvalshape account changes.
Mount the routes
import howdy
import howdy/auth/pages
import howdy/auth/routes
pub fn app(identity) {
howdy.new()
|> howdy.controller(routes.api(identity, at: "/api/auth"))
|> howdy.controller(pages.routes(identity, at: "/auth", api_at: "/api/auth"))
}
routes.api is the JSON API. pages.routes adds starter pages that call
it: /auth/register, /auth/login, /auth/mfa and /auth/account, plus
/auth/password/register and /auth/password/login when passwords are on.
The account page grows controls for whatever you turn on: passwords,
passkeys, second factors, linked providers, sessions and account changes.
The pages are deliberately plain and need JavaScript. Leave pages.routes
out to write your own; Signing in lists the
endpoints they would call.
Providers and SSO sign in through
browser redirects, and have routes of their own: routes.providers and
routes.sso.
Protect your routes
auth.required(identity) is a guard. On a
guarded controller, the signed-in user arrives on ctx.guard:
import howdy/auth
import howdy/auth/user
import howdy/controller
pub fn account_controller(identity) {
controller.guarded("/account", auth.required(identity))
|> controller.get("/me", fn(ctx) {
controller.json(ctx, user.to_json(ctx.guard.user))
})
|> controller.build()
}
For one route on an ordinary controller, use guard.require:
use principal <- guard.require(ctx, auth.required(identity))
The value is a user.Principal: the user, a session_id, and the
client the request came from. The user is a user.User with an id, an
email, a group_id, and created_at and updated_at timestamps.
The guard accepts a session cookie or an Authorization: Bearer header,
and rejects a request that carries both. A visitor who is not signed in gets
401 with {"error":"unauthorized"}; the guard never redirects to a login
page, so that choice stays with your app. A visitor who has passed their
first factor but not yet their second is not signed in.
A write made with the cookie must carry an origin header matching the
configured origin, which is what browsers send. That protects your own
guarded routes from CSRF as well as the package's.
Do not change anything in a GET handler.
Testing
Auth works with howdy/testing. Give each test an
in-memory database, and a deliver function that hands the token to the
test instead of sending it:
import gleam/erlang/process
import gloo/adapter/sqlite
import howdy/auth/secret
pub fn register_test() {
let assert Ok(db) = sqlite.start(sqlite.memory())
let assert Ok(_) = my_app.migrate(db)
let inbox = process.new_subject()
let identity =
my_app.identity(db, fn(delivery: auth.Delivery) {
process.send(inbox, secret.reveal(delivery.token))
Ok(Nil)
})
let res =
testing.post(
"/api/auth/register",
json.object([#("email", json.string("ada@example.com"))]),
)
|> testing.send(my_app.app(identity))
assert res.status == 202
let assert Ok(token) = process.receive(inbox, 1000)
let assert Ok(session) = auth.exchange(identity, token)
assert session.user.email == "ada@example.com"
}
