Signing in
Out of the box, howdy_auth signs people in with a token sent to their
email address:
- The visitor gives their address. Your app emails them a token.
- They paste the token into the page.
- The app exchanges the token for a session.
There is no password to choose, forget or leak, and owning the inbox is the
proof. Passwords, passkeys
and social providers can be added as other ways in.
An app that only uses providers can leave email tokens out altogether, with
auth.new_without_email.
Registration works the same way, and the account is only created once the
token has been exchanged, so every account has a working address.
Registration is off until you call auth.allow_registration.
Sending the email
The deliver function you give auth.new is called with an
auth.Delivery: the email to write to, the token, and a purpose.
The quickest way to write one is howdy/auth/emails, which has a
ready-made email for every purpose, sent with
Howdy Mail:
import howdy/auth
import howdy/auth/emails
import howdy/mail
import howdy/mail/smtp
let assert Ok(config) = smtp.from_env()
let mailer =
mail.mailer(smtp.adapter(config))
|> mail.default_from(mail.named("Acme", "hello@acme.test"))
let auth_emails = emails.new(mailer, app_name: "Acme")
let assert Ok(identity) =
auth.new(repo: db, origin:, deliver: emails.deliver(auth_emails))
Each email is tagged auth.<purpose>, such as auth.sign_in, and
emails.previews(auth_emails) gives the admin area a
preview of every one.
emails.with_template(auth_emails, for: auth.SignIn, build: ...) replaces
one with your own.
You can also write deliver yourself:
import gleam/result
import howdy/auth
import howdy/auth/secret
import howdy/mail
pub fn send_email(mailer: mail.Mailer, delivery: auth.Delivery) {
let subject = case delivery.purpose {
auth.SignIn -> "Your sign-in token"
auth.Registration -> "Confirm your new account"
auth.AlreadyRegistered -> "You already have an account"
auth.EmailChange -> "Confirm your new email address"
auth.EmailChangeApproval -> "Approve changing your email address"
auth.EmailChanged -> "Your email address was changed"
auth.PasswordChanged -> "Your password was changed"
}
mail.message()
|> mail.to([mail.address(delivery.email)])
|> mail.subject(subject)
|> mail.text(
"Paste this token into the page: " <> secret.reveal(delivery.token),
)
|> mail.send(mailer, _)
|> result.replace(Nil)
|> result.replace_error(Nil)
}
Give auth.new the function send_email(mailer, _).
AlreadyRegistered arrives when somebody registers an address that has an
account. The token it carries signs them in, so say that, instead of
inviting them to register again. Whoever made the request sees the same
answer in every case, so only the owner of the inbox learns anything.
The other four purposes come from account changes and password changes:
-
EmailChangeandEmailChangeApprovalcarry tokens that confirm or approve a new address. They cannot be used to sign in. -
EmailChangedandPasswordChangedare notices, and their token is empty. Tell a reader who did not make the change what to do: contact you, or reset their password from a fresh email sign-in.
The token is a secret.Secret, which does not print its contents when the
record around it is logged. Call secret.reveal only where the email is
written. Do not log tokens, and do not put them in links: they are exchanged
by POST, never from a URL.
A token is random, works once, and lasts ten minutes. Returning Error(Nil)
from deliver cancels it. Exchanging a token spends it even when the
exchange then fails, for instance because the user is suspended.
While you develop, print the token instead of sending it:
fn(delivery: auth.Delivery) {
io.println(delivery.email <> ": " <> secret.reveal(delivery.token))
Ok(Nil)
}
Asking twice
A request for a token that finds a usable one already waiting for that address sends nothing, and still reports success, because there is already one in the inbox. A new request never cancels tokens sent before it: the three most recent stay valid.
That is what stops a stranger from flooding somebody's inbox, or from locking its owner out by asking again just before they paste. It holds however many machines the stranger asks from, because the limit belongs to the inbox and not to the asker. The numbers are part of the policy.
The JSON API
routes.api(identity, at: "/api/auth") mounts these, relative to the mount
path:
-
POST /registerwith{"email":"…"}sends a registration token. Answers202. -
POST /loginwith{"email":"…"}sends a sign-in token. Answers202whether or not the address has an account. -
POST /sessionwith{"token":"…"}exchanges a token. Answers with the user as JSON, and sets the session cookie. -
POST /tokenwith{"token":"…"}exchanges a token for a bearer token:access_token,token_type,expires_inanduser. -
GET /meanswers with the signed-in user. -
GET /sessionslists the caller's sessions. -
POST /sessions/revokewith{"id":"…"}ends one of them. Answers204. -
POST /logoutwith a JSON body such as{}ends this session. Answers204.
With passwords on there are more under
/password, and passkeys,
second factors, several accounts
and account changes each add their own. The endpoints that take an email also take an optional
"group": see Groups.
A user with a second factor does not get a session from
/session or /token straight away. The answer is 202 with
{"mfa_required": true}, and the code is checked at /mfa/verify or
/mfa/token.
The user is id, email, group_id, and created_at and updated_at as
RFC 3339 strings. Errors have the usual
Howdy shape.
Every POST must have the content type application/json. Responses carry
cache-control: no-store. Endpoints that send email, exchange tokens or
check passwords allow each client 30 requests a minute, and the signed-in
ones allow 300; behind a proxy, see
Behind a proxy.
From a browser
Use the cookie endpoints. The cookie is HttpOnly, so scripts on the page
cannot read it, and it is sent with every request to your app:
await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email}),
});
// Once the visitor has pasted the token:
const response = await fetch('/api/auth/session', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token}),
});
POST /session requires the origin header to match the origin you
configured, which a browser on your own site sends without being asked.
Avoid /token in a browser: a bearer token kept in JavaScript storage can
be read by any script on the page.
The cookie is named __Host-howdy_session on HTTPS and howdy_dev_session
on a loopback origin; auth.cookie_name(identity) tells you which. It is
Secure, HttpOnly, SameSite=Lax and scoped to /.
From other clients
A native app or a script exchanges at /token, and sends the result with
each request:
Authorization: Bearer <access_token>
These clients need not send origin. A request whose origin is present
and wrong is refused, as is one carrying both a cookie and a bearer token.
Without HTTP
Every endpoint is a thin layer over a function that returns a
service.Result, with no HTML, cookies or
redirects. Use them to build server-rendered forms, or another transport
altogether:
-
auth.request_token_from(identity, email, auth.Login, client), orauth.Register -
auth.exchange_from(identity, token, client)returns anauth.Session: theuser, the secrettoken, andexpires_at -
auth.authenticate_from(identity, token, client)returns theuser.Principalfor a session token -
auth.sessions,auth.revoke_sessionandauth.logout
With multi-factor authentication on, use
auth.exchange_step instead of auth.exchange. It says whether the user is
signed in, or must pass a second factor first.
client is a string naming where the request came from, normally the IP
address. It is used for throttling and recorded in the audit trail. The
shorter names request_token, exchange and authenticate leave it out,
and share one bucket between all callers.
A transport of your own is responsible for what the routes did for you:
checking origin on cookie writes with auth.check_origin, setting the
cookie, no-store on responses, and limiting how fast clients can call.
