ajo-kit-auth
v0.6.1
Published
Authentication and authorization for ajo-kit applications
Maintainers
Readme
ajo-kit-auth
Authentication and authorization for ajo-kit apps.
Includes:
- session auth (cookie)
- bearer tokens with abilities and optional subject scope
- CSRF middleware
- route guards
- in-memory rate limiting
- password reset tokens
- email verification signatures
- single-use account invitations
Install
pnpm add ajo-kit-authajo-kit-auth requires ajo-kit as a peer dependency.
Setup
1. Configure DB accessor
Call configure() once during app boot so auth modules can access your Kysely instance.
import { configure } from '@kit/auth'
import { db } from '/src/data'
configure(() => db())2. Run migrations
ajo-kit-auth exposes kit.migrations, so with the package installed:
kit migrate upThis creates the auth, passkey, team, and invitation tables.
3. Register auth middlewares
// src/wares.ts
import { wares } from '@kit/auth'
export default [wares.session(), wares.csrf]session() resolves req.user from cookies. Bearer tokens authenticate
/api/* routes, where an explicit Bearer token takes precedence over a session
cookie.
csrf validates unsafe cookie-auth requests, including /api/*. It skips safe
methods, bearer-token requests, and unauthenticated API requests. On a managed
App with multiple origins, the browser Origin or Referer must match the current
request origin. A form on one alias does not authorize a request to another
alias, even when both belong to the same App.
4. Set secret for verification links
APP_SECRET=<32+ random characters from your secret manager>Development can run without this value. Production fails closed when
APP_SECRET is missing, too short, or left as a sample placeholder.
For non-local production, also configure APP_URL in the app environment for
canonical generated links. When the host supplies a managed origins manifest,
APP_URL must be an exact HTTPS origin listed there; form checks use the current
request origin through requestOrigin(req) from ajo-kit.
Main Exports
The package root exports the APIs below.
password
import { password } from '@kit/auth'Argon2id hash/verify helpers.
session
import { session } from '@kit/auth'
const id = await session.create(user, remember, ip, agent)
const active = await session.validate(id)
await session.touch(id)
await session.remove(id)
await session.prune()create() returns the plaintext cookie value. The database stores only a
SHA-256 hash of that value in sessions.id.
Session absolute lifetime is 30 days by default or 365 days with
remember = true. Remembering changes only that absolute limit: validate()
enforces the same 30-minute idle timeout for every session, removes expired
sessions, and updates last at most once every 5 minutes.
Pass activity = false for background checks such as SSE freshness. prune()
removes expired rows.
cookie
import { cookie } from '@kit/auth'
const id = cookie.read(req)
cookie.write(res, id, remember)
cookie.clear(res)HTTPS deployments use __Host-session with
HttpOnly; SameSite=Lax; Path=/; Secure; the __Host- prefix prevents sibling
subdomains from shadowing the host-only session. Local HTTP development uses
the unprefixed session name because browsers require Secure on __Host-
cookies.
csrf
import { csrf } from '@kit/auth'
const token = csrf.set(req, res)
const ok = csrf.verify(req)Verification accepts:
- signed double-submit bound to the current session
(
XSRF-TOKENcookie +X-XSRF-TOKENheader) - same-origin check (
Origin/Refererhost matches request host)
wares
import { wares } from '@kit/auth'session(lookup?) accepts an optional custom user resolver. Bearer token auth is scoped to /api/*; route actions use cookie sessions and CSRF.
Guards
import {
ability,
admit,
auth,
authorize,
confirmed,
guest,
guard,
protect,
redirect,
verified,
when,
} from '@kit/auth'auth()requires an authenticated user.authorize(req, ...abilities)is global-only: it checks global account and bearer-token abilities and rejects subject-scoped tokens whenever abilities are required. With no abilities it checks authentication only.admit(req, subject, ...abilities)is the subject-scoped check: it checks global account abilities plus current team grants for one subject. Bearer tokens must carry the required abilities, and a scoped token must match the subject exactly, even if its owner has global*authority.ability(...abilities)is the middleware form ofauthorize().protect('/login')redirects guests.guest('/dashboard')redirects authenticated users.confirmed(window?)requires recent password confirmation.verified()requires ausers.verifiedtimestamp.when(condition, middleware, otherwise?)selects middleware by request.redirect(target)returns an AJAX-aware redirect middleware.
The same guard functions are available through the guard namespace.
token
import { admit, token } from '@kit/auth'
const plain = await token.create(user, 'Blog CI', ['apps:deploy'], {
subject: 'app:blog',
})
// Return plain to its owner once. Subsequent API requests use Bearer auth.
// In an API handler, after wares.session():
await admit(req, 'app:blog', 'apps:deploy')
const tokens = await token.list(user)
const selected = tokens.find(item => item.name === 'Blog CI')
if (selected) await token.revoke(user, selected.id)
await token.purge(user)
await token.prune()create(user, name, abilities, options?) returns the plaintext credential
once; storage contains only its SHA-256 hash. Without options.subject, the
token is global and issuance checks current global account abilities. With a
subject, issuance checks those abilities together with current team grants
for that subject. Requests exceeding that authority are refused.
Subjects are exact, nonblank opaque strings. They have no wildcard, prefix,
or environment matching. An application that treats production, staging and
previews as one App maps each target to the same canonical subject before
calling admit(). Applications also own query filtering and list visibility;
authentication alone does not restrict returned resources.
Abilities support *, exact matches, and resource wildcards like posts:*.
Use a narrow grant such as apps:deploy for a deployment token. Guards check
both current account authority and the token's abilities on every request,
so losing required authority through a membership, claim, or role change
blocks the affected operation on subsequent requests. A scoped token cannot
satisfy a global ability check.
options.ttl is milliseconds and defaults to 90 days. Scoped tokens require
a finite positive TTL no longer than 90 days. Global tokens also allow
ttl: null for no expiry. validate(plain) rejects expired credentials and
returns the stored identity, including subject; it is not an authorization
check. Middleware exposes { id, abilities, subject } on req.token, with
subject: null for global tokens. Use admit() or authorize() to authorize
an operation.
list(user) exposes full stored IDs, subject, abilities and usage/expiry
metadata without plaintext secrets. revoke(user, id) deletes only a token
owned by that user, clears its confirmation stamp, and returns whether it
was deleted. Unknown and foreign IDs return false; use the full ID rather
than a displayed suffix.
Issuance is a trusted server operation: a route delegating from a bearer must also restrict the new credential to the parent's subject, abilities and remaining lifetime. It must never let a scoped bearer mint a global token. The subject migration's rollback deletes scoped tokens before removing the column, so rollback cannot turn them into global credentials.
Browser code imports ability helpers from the client-safe subpath:
import { all, can, compact, intersect, merge } from '@kit/auth/ability'account
import { account } from '@kit/auth'
const grants = await account.grants(user)
const abilities = await account.abilities(user)account.grants(user) loads assigned role grants. account.abilities(user)
merges them and removes duplicate or redundant wildcard grants. Authorize with
abilities through ability() or authorize(). account.scoped(user, subject)
resolves the abilities a user gains over one subject through team membership;
global grants stay out on purpose — admit() composes both.
team
import { admit, team } from '@kit/auth'
const id = await team.create('platform')
await team.join(id, user, role)
await team.claim(id, 'app:blog')
await admit(req, 'app:blog', 'apps:operate')Teams are subject-scoped authorization groups, not tenants or organizations.
They have no active request context, settings, resource ownership, or data
isolation; applications own resource isolation and query scoping. Each
teammate holds a role from the same roles catalog global members use. A claim
records that the team holds a subject — an opaque string your app defines (an
app name, a project id, a customer). Authority composes one way: global grants
always apply everywhere; on top, for one subject, a user gains the abilities
of every role they hold in every team claiming it. A bearer token can narrow
that authority further through its abilities and subject.
create(name)/rename(team, name)/remove(team)/get(team)/list()— lifecycle;list()carries member and claim counts.join(team, user, role)— one membership per team and user; joining again changes the role.leave(team, user)removes it.members(team)— users with their role names.claim(team, subject)(idempotent) /release(team, subject)/claims(team)/holders(subject).of(user)— the user's teams with role names.subjects(user)— every subject reachable through any membership, for scoping list views.
invite
import { invite } from '@kit/auth'
const token = await invite.create({
role: 'member',
email: '[email protected]',
name: 'Person',
inviter: user,
})
const pending = await invite.get(token)
const account = await invite.accept(token, { passwordHash })
await invite.revoke(invitationId)
const invitations = await invite.list()create() returns a plaintext ajoinv_ token once; the database stores only
the SHA-256 hash of the complete token. Invitations expire after seven days by
default. Supplying an email binds acceptance to its normalized value and
revokes any previous pending invitation for that email. Without an email, the
acceptor supplies one and invitations are not deduplicated.
The invitation carries a role name and optional team. Creation fails closed
when that team does not exist. Acceptance resolves the role from the roles
catalog and fails closed when it is unknown. A team invitation creates a
teammates row; a global invitation creates a members row. passwordHash
is stored verbatim. An account is marked verified only when the invitation was
email-bound and a password hash was supplied; caller-supplied acceptance emails
and credential-less accounts remain unverified.
A credential-less account can revisit the invitation while completing a
passkey ceremony. The window closes as soon as the account gains either a
password or a WebAuthn credential. list() returns only unexpired invitations
that have not been accepted or revoked, using stored ids suitable for
revoke().
limit
import { limit } from '@kit/auth'
if (!limit.check(ip)) throw new Error('Too many attempts')
limit.hit(ip, 60_000)
limit.remaining(ip)
limit.clear(ip)The limiter stores counters in process memory. Multi-process deployments require a shared limiter.
confirm
import { confirm } from '@kit/auth'
confirm.stamp(req)
confirm.check(req, 180_000)
confirm.clear(req)
confirm.clearSession(user, sessionId)
confirm.clearToken(user, tokenId)
confirm.clearUser(user)Tracks recent password confirmation in memory, scoped to the current session or bearer token credential.
reset
import { reset } from '@kit/auth'
const plain = await reset.create(user)
const preview = await reset.validate(plain)
const user = await reset.consume(plain, passwordHash)
await reset.prune()Reset tokens are SHA-256 hashed in DB and expire in 1 hour. validate() is a
read-only preview for a GET page; consume() is the atomic password-change
boundary and revokes the user's sessions, API tokens, and other reset tokens.
verify
import { verify } from '@kit/auth'
const link = verify.url(user, email, 'https://example.com')
const verifiedUser = await verify.validate(signature)HMAC-SHA256 signed token bound to the normalized current email, default expiry
24 hours. A matching already verified account succeeds without another write.
The link remains replayable until expiry, but can only affirm the exact address
it was minted for. Production requires a strong APP_SECRET.
passkey
WebAuthn, implemented here rather than depended upon: registration asks for
attestation: 'none', which is what every mainstream passkey provider emits,
so there is no attestation statement to verify and no certificate chain to
walk — what remains is CBOR, fixed-offset parsing, and signatures node:crypto
verifies natively. Accepts ES256, EdDSA and RS256; dropping RS256 would lock
out Windows Hello over a TPM.
import { passkey } from '@kit/auth'
// Once, at startup. Never derived from a request header: the browser puts the
// real address bar origin into client data, and deriving what it is compared
// against from `Host` would let the caller choose.
passkey.configure({ rpId: 'example.com', origins: ['https://example.com'] })
// Registration, two requests.
const options = await passkey.registration({ id: user.id, name: user.email })
const id = await passkey.register(user.id, response)
// Authentication, two requests. Ends where password.verify ends.
const options = await passkey.authentication()
const user = await passkey.authenticate(response)
await passkey.list(user)
await passkey.remove(user, id)
await passkey.prune()Challenges are rows, single-use, and expire on the redemption path — a window
enforced only by a sweeper nobody schedules is not a window. prune() reclaims
the rows; expiry does not depend on it.
The relying party id is permanent. Credentials are bound to rpId for
life and there is no migration: passkeys registered against localhost (an
SSH tunnel, say) will not be offered when the same host is later served at a
real name, and the browser will not even show them. Register the durable name
from the start where one exists; treat tunnel credentials as disposable where
it does not. In production use the apex (example.com, never
app.example.com) — any subdomain can assert against the apex, not the
reverse.
The counter is recorded and not enforced: passkeys synced through iCloud or Google report zero from every device by design, so a regression is a note for whoever reads the row, never a reason to refuse. What is enforced: backup eligibility cannot change in either direction, and a credential registered with the person verified cannot later be used on presence alone.
Types
import type { Ability, Auth, Invite, New, Session, Team, Token, User } from '@kit/auth'