@agentoria/authkit
v1.2.0
Published
A framework-agnostic, storage-agnostic authentication toolkit built on Better Auth — email/password, phone OTP, OAuth, and RFC 8628 device authorization, with a CLI, an SDK, and a REST API. Runs on Node, Cloudflare Workers, Deno, and Bun.
Downloads
2,085
Maintainers
Readme
authkit
A framework-agnostic, storage-agnostic authentication toolkit built on Better Auth — email/password, phone OTP, OAuth, and RFC 8628 device authorization, with a CLI, an SDK, and a REST API. Runs on Node, Cloudflare Workers, Deno, and Bun.
Same shape as its siblings paykit and plankit: one port, several adapters,
no framework dependency.
Status: 0.x — installable, not yet stable. Core, web, SDK and CLI (P1–P3 of
docs/design.md§9), with one consumer. The API settles at 1.0, after a second app has had a say; until then a breaking change is a minor bump.
Install
pnpm add @agentoria/authkit better-authbetter-auth is a peer dependency, and an optional one only in the narrow sense
that @agentoria/authkit/client — the fetch-and-types SDK a CLI or a browser
talks to a server with — does not need it. Everything server-side does.
| import | what it is | needs better-auth |
| --- | --- | --- |
| @agentoria/authkit | config, cookies, sessions, schema, handler | yes |
| @agentoria/authkit/web | the (Request) => Response \| null handler alone | yes |
| @agentoria/authkit/schema | DDL you commit to your own migrations | yes |
| @agentoria/authkit/stores/memory | tests and throwaway dev data | yes |
| @agentoria/authkit/client | typed SDK, device flow included | no |
| @agentoria/authkit/cli | login / logout / whoami, and the token file | no |
| @agentoria/authkit/sms | routing + the phoneNumber plugin's callbacks | no |
| @agentoria/authkit/sms/aliyun | Aliyun Dypnsapi, ACS3-HMAC-SHA256 signing | no |
| @agentoria/authkit/sms/twilio | Twilio Verify | no |
| @agentoria/authkit/oauth/wechat | 微信扫码登录 (qrconnect) | no |
| @agentoria/authkit/email/templates | the auth emails, zh/en | no |
| @agentoria/authkit/passwords/pbkdf2 | PBKDF2 hashing, with rehash-on-sign-in | no |
| @agentoria/authkit/drizzle | does your ORM describe the database you have? | no |
/cli is the only module that touches node:fs, and it is deliberately not
re-exported from the root — so import "@agentoria/authkit" stays loadable in a
Worker.
The provider adapters are the one place this library implements anything rather
than configuring Better Auth, and they exist because Better Auth has no opinion
about Aliyun, Twilio or WeChat and never will. They plug into seams it leaves
open: phoneNumber's sendOTP / verifyOTP, and the app's own mail callbacks.
They came from an earlier hand-rolled authkit — see
docs/design.md §3 for why its session and password code did
not.
Why
Seven apps in this estate each grew their own authentication — six different
session designs, with PBKDF2_ITERATIONS = 100_000, a timingSafeEqual, a
sha256Hex and a sessions table re-derived independently in three or four
places each.
Duplication is the cheap complaint. The expensive one is that each copy drifted differently, and the drift is where the vulnerabilities were: a stream endpoint that accepted expired and scoped tokens because it hand-rolled its own resolution; an OAuth sign-in that merged a verified identity into an account whose address had never been proved; a logout that cleared a cookie name that was never set; session tokens stored as plaintext primary keys.
authkit exists so those decisions are made once, tested once, and fixed once.
docs/design.md §1 lists every defect and the design rule it argues for.
That includes upstream defaults it overrides. Better Auth stores a verification
row's identifier exactly as it was sent, and that identifier is the secret half
of every out-of-band flow — the token in a password-reset email, magic links,
email and phone one-time codes, two-factor challenges, OIDC and MCP
authorization codes. A live reset token replaces a password, so read access to
that table is an account takeover needing no write, no login and no password.
authkit sets verification.storeIdentifier: "hashed". Lookup falls back to the
raw identifier, so links already mailed keep working and nothing needs
migrating; an app that wants the upstream behaviour can still ask for
verification: { storeIdentifier: "plain" }.
The same applies to what a reset does. Upstream leaves
revokeSessionsOnPasswordReset off, so completing one gives the account a new
password while whoever was already signed in stays signed in — and a reset is
usually how somebody tries to evict them. authkit turns it on wherever an app
configures emailAndPassword, and an app that wants otherwise can say so. Its
sibling stays the caller's: changePassword takes revokeOtherSessions in the
request body, which no library default can supply.
Use
import { createAuthkit, createAuthHandler, schemaSQL } from "@agentoria/authkit";
const auth = createAuthkit({
secret: env.AUTH_SECRET, // required; keys the HMAC over the cookie
baseURL: "https://app.example.com",
database: env.DB, // a D1 binding, a pg Pool, node:sqlite, …
});
// Compose it: returns null for requests that are not auth's, so the host
// keeps its own routes. Same shape as paykit and plankit.
const handler = createAuthHandler(auth, { basePath: "/api/auth" });
export default {
fetch: async (request) => (await handler(request)) ?? myRouter(request),
};Tables come as SQL you commit, not as a tool that reaches into your database:
console.log(schemaSQL(auth, "sqlite")); // paste into your next migrationschemaSQL reads the configured instance, so enabling a plugin adds its
tables to the output. Regenerate after changing plugins.
Revocation is named rather than inferred — revokeSession,
revokeOtherSessions, revokeAllSessions. Clearing a cookie is not signing
out; it removes the browser's copy of a credential that still works.
Signing someone in without a password
An OAuth callback that verified a profile, an admin-key exchange, an invitation link, a test fixture — each holds a user id and needs the session that would have followed:
import { startSession } from "@agentoria/authkit";
const { value, setCookie, sessionId } = await startSession(auth, user.id);All three describe one session, from one mint. value goes in a Cookie
header, setCookie is a ready Set-Cookie, sessionId answers "which session
is this" without recomputing it.
Pass where the sign-in came from and the session records it, so an account's security page can name the device and address instead of showing blanks:
await startSession(auth, user.id, { ipAddress: clientIp(request), userAgent });Values, not headers: every app here already resolves the client address for rate
limiting, and re-deriving it inside authkit would give one app two answers about
who called. advanced.ipAddress.disableIpTracking still wins — an app that has
promised not to store addresses does not start because a caller passed one.
The stored token is deliberately not returned. The cookie is an HMAC over it, which is what stops a dumped session row from being replayable — so handing back both would make presenting the wrong one an ordinary slip, and that slip turns a database read into a session.
A link that works once
A password-reset link, an email-change confirmation, an invitation — mint an unguessable string, mail it, redeem it exactly once before it expires:
import { issueOneTimeToken, redeemOneTimeToken } from "@agentoria/authkit";
const token = await issueOneTimeToken(auth, {
kind: "password-reset",
value: String(user.id),
ttlMinutes: 60,
});
// …mail it, then, when the link is opened:
const userId = await redeemOneTimeToken(auth, { kind: "password-reset", token });kind is part of the stored identifier, so a token minted for one purpose
cannot be redeemed for another — an email-change token names an address its
holder proved they control, and honouring it as a password reset would hand them
that account. null covers every failure (unknown, spent, expired, wrong kind)
with one answer, because telling a holder which one it was tells whoever is
guessing how close they are.
This is storage, not a flow. Better Auth's requestPasswordReset also decides
how the email reads, which sessions to revoke and how the reset is rate-limited;
an app that already has answers to those keeps them and stops keeping its own
auth_tokens table. Redemption is atomic — two requests racing for one token
produce one winner, checked against a real database rather than assumed.
From a CLI
The device flow (RFC 8628), for anything without a browser:
import { login, logout, whoami } from "@agentoria/authkit";
await login({ baseURL: "https://app.example.com" });
// Open https://app.example.com/api/auth/device?user_code=BCKLFB4K
// and enter the code: BCKLFB4K
// Waiting for approval…
// Signed in.The token is stored 0600 and logout ends the session server-side before
removing it — deleting the file only removes this machine's copy of a
credential that still works everywhere else.
bearer is installed by default, and that is not cosmetic: without it Better
Auth ignores Authorization: Bearer entirely, so the device flow completes,
prints success, and every later call is anonymous. Plugins you pass are added
to the defaults rather than replacing them, so you cannot lose it by accident.
After adopting into an app that already has a users table
Pointing Better Auth at your existing tables gives each of them a third writer — Better Auth's adapter, your ORM, and your migrations — each carrying its own description of the shape, with nothing reconciling them. On SQLite a disagreement is not an error: it is a value that reads back cleanly and wrongly. Your types cannot catch it (the ORM declaration is what they check against) and neither can your suite (the fixtures write through that same declaration).
So derive both sides and compare them, once:
import { describeDrift, schemaDrift } from "@agentoria/authkit/drizzle";
import * as schema from "../src/db/schema";
test("the schema declares the database the migrations produce", async () => {
const drift = await schemaDrift(schema, (sql) => db.prepare(sql).all());
expect(describeDrift(drift)).toBeNull();
// A check that examined nothing is also clean. Rename the schema file and the
// first line goes on passing for as long as anybody cares to look.
expect(drift.comparedTables.length).toBeGreaterThan(20);
});That one assertion is what catches the migration that converted created_at to
TEXT while the schema file still says integer(mode: "timestamp") — the shape
of failure this hits first, and the one that reads back as Invalid Date rather
than as an error. drizzle-orm is an optional peer; nothing else here needs it.
Not every disagreement is a defect. SQLite's declared type is an affinity, not a constraint, so a column declared INTEGER holding ISO-8601 text behaves exactly as TEXT would — and converting the rows without rebuilding the table (which means dropping and recreating every inbound foreign key) is often the right call. Say so, and say what the database has:
const drift = await schemaDrift(schema, read, {
// 0014 converted every row to ISO-8601 text and left the column INTEGER:
// rebuilding `users` would mean dropping eight inbound foreign keys.
accept: { "users.created_at": "integer" },
});The value is what the database has, so this accepts one specific disagreement rather than switching a column off. A later migration that makes it REAL is a new fact and fires again; one that finally converts it to TEXT reports the entry as stale, so the exemption cannot outlive its reason.
Scope
In: email + password, phone OTP, OAuth (GitHub / Google / generic OIDC), sessions and revocation, API keys, device authorization, and the three surfaces every app needs — REST, SDK, CLI.
Out: authorization (roles, teams, entitlements stay in the apps and in
plankit), non-TypeScript apps, and anything rendered — login forms belong to
the app's design system.
License
MIT
