npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ajo-kit-auth

v0.6.1

Published

Authentication and authorization for ajo-kit applications

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-auth

ajo-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 up

This 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-TOKEN cookie + X-XSRF-TOKEN header)
  • same-origin check (Origin/Referer host 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 of authorize().
  • protect('/login') redirects guests.
  • guest('/dashboard') redirects authenticated users.
  • confirmed(window?) requires recent password confirmation.
  • verified() requires a users.verified timestamp.
  • 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'