Howdy

Howdy Auth

howdy_auth is an optional package that signs people in to a Howdy app. It gives you:

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.1.0", path = "auth" }
howdy_database = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.1.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)
}
  • repo is your database. With SQLite, auth and your own code can share it, as long as your code goes through howdy/database. If your code calls gloo/repo directly while requests are being served, give auth a second Repo of its own, opened on the same file.
  • origin is the public address of your site: scheme, host and optional port. It must be https, except for http://localhost, http://127.0.0.1 and http://[::1] while you develop. Auth never trusts the host header to work this out.
  • deliver sends an email. See Sending the email. An app that only uses providers can use auth.new_without_email instead.

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_deletion and auth.with_email_change_approval shape 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"
}