auth
| Package | @theoven/auth |
| Adds to context | ctx.user (Identity | null), ctx.auth (the provider) |
| Endpoints | none itself; a provider may mount its own |
| Creates files | none |
| Creates tables | none; storage bricks own their tables |
| Status | shipped |
Install
Section titled “Install”Pair it with a provider — auth-basic for email and password, or a hosted one.
bun add @theoven/auth @theoven/auth-basicimport { auth } from '@theoven/auth'import { basicAuth } from '@theoven/auth-basic'import { policies } from './policies'
export const app = createApp() .use(db(drizzleSqlite({ url: './data.db', schema }))) .use(auth(basicAuth({ db: client, secret: env.string('AUTH_SECRET') }), { policies }))What it does
Section titled “What it does”The auth contract, not an implementation. It defines what a provider is, enforces route
guards, runs your named policies, and puts ctx.user on the context typed to whichever provider
you registered.
It also owns the security-critical half of password auth — argon2id hashing, JWT signing, single-use reset tokens, refresh rotation — so that storage bricks implement seven storage methods and nothing security-sensitive (D26). See Flows.
Alone it authenticates nobody. Pair it with a provider:
| Provider | For |
|---|---|
auth-basic |
email and password, stored with Drizzle |
auth-mongo |
the same, stored with Mongoose |
auth-clerk |
Clerk-hosted sign-in, verified locally |
auth-better |
better-auth mounted inside your app |
Endpoints
Section titled “Endpoints”This brick mounts none. A provider declaring the routes capability mounts its own under
prefix — auth-basic and auth-mongo add eight endpoints at /auth/*, auth-better forwards
everything under it, and auth-clerk adds nothing at all.
Configuration
Section titled “Configuration”auth(provider, { prefix: '/auth', policies })| Option | Default | |
|---|---|---|
prefix |
/auth |
where a route-mounting provider is mounted |
policies |
{} |
named authorization rules, see Policies |
Everything else — secrets, token lifetimes, rate limits — belongs to the provider, on its own page.
What it creates
Section titled “What it creates”Nothing. No files, no tables. Storage bricks own their tables and list them on their own pages.
Guarding a route
Section titled “Guarding a route”app.get('/me', { auth: true }, (ctx) => ctx.user) // 401 when anonymousapp.get('/admin', { auth: 'admin' }, (ctx) => ...) // 403 unless the policy passesapp.get('/public', (ctx) => ctx.user?.id ?? 'anonymous') // open, user may still be presentThe guard runs before the handler, so a guarded route is unreachable rather than merely
inconvenient to reach. A 401 carries WWW-Authenticate: Bearer, which is what tells a client
how to authenticate.
Identity
Section titled “Identity”interface Identity<Raw> { id: string email?: string name?: string image?: string raw: Raw // everything the provider returned, typed per brick}Only what every provider genuinely has. Roles, organisations and metadata are not normalised:
several providers have no such concept, and a field that is permanently [] misleads worse than
one that is absent (D17).
user.raw — the escape hatch
Section titled “user.raw — the escape hatch”Everything the provider returned, untouched. It is where anything not in the four fields above lives, and it is what a policy reads.
What it contains depends entirely on which provider is registered:
| Provider | raw is |
What you would read |
|---|---|---|
auth-basic |
the auth_users row |
any column you added — role, plan, emailVerifiedAt |
auth-mongo |
the user document | any field you added to the collection |
auth-clerk |
the JWT claims | org_id, org_role, publicMetadata — whatever your token template includes |
auth-better |
better-auth’s user object | anything from its additionalFields |
It is typed per adapter, so user.raw.role autocompletes on auth-basic and is a compile
error on auth-clerk, where the equivalent claim is user.raw.org_role. That is deliberate: the
type is telling you the truth about where your data actually is.
Where should a role live?
Section titled “Where should a role live?”raw reaches whatever the provider stores. Whether the provider should store it is a separate
question, and the answer splits cleanly:
Put it in raw |
Put it in your own table | |
|---|---|---|
| Scope | one value per user, app-wide | per resource — this board, that organisation |
| Example | role: 'admin', plan: 'pro' |
board membership, team roles, project permissions |
| Read cost | free — already on ctx.user |
a query, per request that needs it |
| Portability | tied to the provider | survives changing provider |
An app-wide role column on auth_users is reasonable and cheap. A per-board role is not a
property of the user at all, and putting it on the user is how you end up with a roles array
that has to be rebuilt whenever anything changes. The
Trello project works through the second case.
Policies
Section titled “Policies”Since roles are not normalised, authorization is a named function you write:
export const policies = { admin: (user) => user.raw.role === 'admin', owner: (user, ctx) => user.id === ctx.params.userId,}app.get('/users/:userId/billing', { auth: 'owner' }, handler)app.delete('/posts/:id', { auth: ['admin', 'owner'] }, handler) // all must passA policy receives the identity and the context, and returns a boolean or a promise of one:
type Policy<Raw> = (user: Identity<Raw>, ctx: Context) => boolean | Promise<boolean>It works on any provider, is greppable, is named in the generated OpenAPI document — and, because it is a plain function, is testable without an app:
import { expect, test } from 'bun:test'import { policies } from './policies'
test('admin is the role, not the email domain', () => { expect(policies.admin({ id: 'u1', raw: { role: 'admin' } } as never, {} as never)).toBe(true) expect(policies.admin({ id: 'u2', raw: { role: 'member' } } as never, {} as never)).toBe(false)})That is the argument for a function over a role: 'admin' field on the route. A field would have
to mean the same thing on every provider, and it does not — so a portable-looking guard would fail
silently on the one provider that has no roles.
The cost of reading raw in a policy
Section titled “The cost of reading raw in a policy”A policy that reads user.raw.role is tied to the provider that produced it. Moving from
auth-basic to Clerk means rewriting that policy, because the role genuinely moved from a database
column to a JWT claim.
That is honest rather than unfortunate — the alternative is a framework that pretends the two are the same and breaks quietly when they are not. If you expect to switch, read the provider-specific shape in one place:
// The only line that knows where a role is stored. Changing provider changes this, not every rule.const roleOf = (user) => user.raw.role ?? 'member'
export const policies = { admin: (user) => roleOf(user) === 'admin', editor: (user) => ['admin', 'editor'].includes(roleOf(user)),}Several policies on one route
Section titled “Several policies on one route”app.delete('/posts/:id', { auth: ['admin', 'owner'] }, handler)All of them must pass — an array is and, not or. There is no built-in or because the
combination people reach for is almost always “admin, or the owner”, and writing that as one named
policy makes the rule readable at the route:
adminOrOwner: (user, ctx) => roleOf(user) === 'admin' || user.id === ctx.params.userId,Social sign-in
Section titled “Social sign-in”Google and GitHub are built into the password providers — auth-basic
and auth-mongo — and off until configured.
import { auth, github, google } from '@theoven/auth'import { basicAuth } from '@theoven/auth-basic'
auth( basicAuth({ db: client, secret: env.string('AUTH_SECRET'), callbackUrl: (provider) => `${env.string('APP_URL')}/auth/oauth/${provider}/callback`, oauth: { google: { provider: google, clientId: env.string('GOOGLE_ID'), clientSecret: env.string('GOOGLE_SECRET') }, github: { provider: github, clientId: env.string('GITHUB_ID'), clientSecret: env.string('GITHUB_SECRET') }, }, }),)Each configured provider mounts two endpoints and nothing else:
| Method | Path | |
|---|---|---|
GET |
/auth/oauth/:provider |
redirects to the provider |
GET |
/auth/oauth/:provider/callback |
completes the sign-in |
It produces the same session
Section titled “It produces the same session”An OAuth sign-in issues the same short access JWT and revocable refresh row a password login
does (D20). So auth: true, your policies, logout, sign-out-everywhere and
password-change-invalidates-sessions all keep working, and an OAuth session is revocable like any
other.
Adding social sign-in touches no route you have written.
Flows are independently optional
Section titled “Flows are independently optional”basicAuth({ db: client, secret, password: false, callbackUrl, oauth: { google: googleConfig } })password: false mounts no signup, login, forgot-password, reset-password or
change-password. The session endpoints — refresh, logout, me — stay, because a session is a
session however it began.
When a provider email matches an existing user
Section titled “When a provider email matches an existing user”The rule, and it is the security-critical part (D33):
| The provider account is already linked | sign in as that user, whatever the email now says |
| The email matches a user and the provider verified it | link, and sign in |
| The email matches but the provider did not verify it | refuse, with a message saying to sign in and link from settings |
| No match | create the user |
The third row is the one that exists for a reason. A provider that lets someone claim an address
they do not own would otherwise hand them somebody else’s account. Google’s email_verified and
GitHub’s verified flag are both real checks, so both are safe to link on — the rule is what keeps
that true when a third provider is added.
Identity is keyed on the provider’s subject id, never the email, so someone who changes their address at Google is still the same user here.
A provider that returns no email is refused
Section titled “A provider that returns no email is refused”GitHub omits the email unless the user:email scope is granted, and a user may have none verified.
Since email is the anchor for linking and for recovery, an account without one could never be
linked or recovered — so the sign-in fails with a readable message instead (D34).
Provider tokens are not stored
Section titled “Provider tokens are not stored”oauth: { github: { provider: github, clientId, clientSecret, storeTokens: true } }Off by default (D35). Most applications want sign-in, not the provider’s API, and tokens nobody reads are a liability rather than a feature. Opt in per provider when you actually need to call them.
Users created this way have no password
Section titled “Users created this way have no password”They are stored with an unusable password — a value nothing can verify — rather than a null
one, which is what avoids a breaking migration for every existing installation. change-password
on such a user answers 409; they can set a first password through forgot-password →
reset-password like anyone else.
Unlinking a provider is refused when it would leave someone with no way to sign in at all.
Capabilities
Section titled “Capabilities”identify() is the only method a provider must implement. Everything else is declared, and a
mismatch fails at boot rather than at 3am:
| Capability | Meaning |
|---|---|
routes |
mounts its own endpoints, e.g. /auth/* |
signOut |
can end a session server-side |
refresh |
can exchange a refresh token |
Providers genuinely differ: Clerk cannot sign a user in from a server, and better-auth cannot not-mount routes. Requiring both to pretend produces adapters that throw in production.
In a route file, the guard is an export and ctx.user is narrowed by it:
export const auth = true
// `user` is `Identity`, not `Identity | null` — the guard above is what proves that.export default async ({ user }) => ({ id: user.id, email: user.email })import { z } from 'zod'
export const auth = 'owner'export const params = z.object({ userId: z.uuid() })
export default async ({ user, params }) => loadBilling(params.userId)Outside a route — in middleware, where there is no schema to read — narrow explicitly:
import { requireUser } from '@theoven/auth'
app.use(async (ctx, next) => { const user = requireUser(ctx.user) // throws 401 if anonymous ctx.log.info('acting', { userId: user.id }) return next()})Reading what the provider can do, at runtime:
ctx.auth.name // 'basic', 'clerk', 'better-auth', …ctx.auth.capabilities?.signOut // false on auth-clerkawait ctx.auth.signOut?.(ctx)Using the pieces directly
Section titled “Using the pieces directly”The eight endpoints are the common case. Everything behind them is exported, for the cases where you need your own endpoint but not your own security.
The flows
Section titled “The flows”import { changePassword, login, logout, refresh, requestPasswordReset, resetPassword, signup,} from '@theoven/auth'Each takes ctx.auth.flows — the same FlowConfig the mounted endpoints use, so password rules,
hashing, token lifetimes and single-use reset semantics cannot drift from them:
export default async ({ auth, body, db }) => { const { user, tokens } = await signup(auth.flows, body) await db.insert(workspaces).values({ ownerId: user.id, name: body.workspace }) return { user, ...tokens }}| Returns | Notes | |
|---|---|---|
signup(flows, { email, password, name }) |
{ user, tokens } |
409 on a duplicate email |
login(flows, { email, password }) |
{ user, tokens } |
identical message and timing for a wrong password and an unknown email |
refresh(flows, token) |
{ user, tokens } |
rotates — the old token stops working |
logout(flows, token) |
void |
deletes the refresh row; twice is not an error |
changePassword(flows, userId, { current, next }) |
void |
ends every other session |
requestPasswordReset(flows, email) |
void |
succeeds silently for an unknown address, so it cannot be used to find accounts |
resetPassword(flows, token, password) |
void |
single use |
Linking a provider from a settings page
Section titled “Linking a provider from a settings page”Signing in with a provider links it as a side effect. Detaching one is a call you make:
import { unlinkAccount } from '@theoven/auth'
export const auth = trueexport const body = z.object({ provider: z.enum(['google', 'github']) })
export default async ({ auth, body, user }) => { await unlinkAccount(auth.flows.store, user.raw, body.provider) return { unlinked: true }}It refuses with a 409 when that provider is the person’s last way of signing in — removing it
would lock them out of their own account, and no confirmation dialog makes that recoverable.
Hashing, for seeding and migration
Section titled “Hashing, for seeding and migration”import { hashPassword, verifyPassword } from '@theoven/auth'
// Importing users from an old system, or seeding a development database.await db.insert(users).values({ ...row, passwordHash: await hashPassword(temporary) })argon2id with Bun’s default cost parameters — not numbers we invented. verifyPassword returns
false for a malformed hash rather than throwing, so a corrupt row fails a login instead of taking
down the endpoint.
Writing a provider
Section titled “Writing a provider”import type { AuthProvider } from '@theoven/auth'
export function myAuth(options): AuthProvider<MyUser> { return { name: 'my-auth', identify: async (ctx) => { const claims = await verify(ctx.token) return claims ? { id: claims.sub, email: claims.email, raw: claims } : null }, }}identify must not throw for an absent or invalid credential — a public route on the same
app still has to work. Return null.
Flows, for storage bricks
Section titled “Flows, for storage bricks”@theoven/auth also ships the security-critical half — argon2id hashing, JWT signing,
single-use reset tokens, refresh rotation — behind a seven-method AuthStore. A storage brick
implements those seven methods and gets every flow.
That is why auth-basic (Drizzle) and auth-mongo (Mongoose) can exist without duplicating
password handling: a security fix landing in one and not the other is the bug nobody finds.
How it is verified
Section titled “How it is verified”The contract is tested with stub providers — deliberately, because what is under test is the
brick’s own logic, not any vendor’s: that auth: true refuses an anonymous request with a 401
carrying WWW-Authenticate: Bearer, that a failing policy gives 403 and not 401, that an
array of policies requires all of them, that an unknown policy name fails closed with a
500 naming the registered ones, that a public route still works while anonymous, and that a
provider declaring routes without a mount() fails at boot.
The flows run here against an in-memory AuthStore — which doubles as a worked example of how
small the contract is — covering signup, login, refresh rotation, logout, password change and
reset. The interesting ones are the security properties rather than the happy paths: an unknown
email fails with the identical message to a wrong password and in comparable time, a rotated
refresh token stops working, a reset token is single-use, a forgotten-password request for an
unknown address succeeds silently rather than confirming absence, and changing a password ends
every other session.
Crypto is tested separately: argon2id round-trips, the same password hashes differently each time, a malformed hash returns false rather than throwing, tokens are stored only as SHA-256, and access tokens are refused when expired, tampered with, or signed by another secret. Signing without a secret throws rather than signing with nothing.
That AuthStore is a real contract and not a description of its first implementation is proven
elsewhere: the shared describeAuthStore suite from @theoven/auth/testing is run by
auth-basic against SQLite and auth-mongo
against MongoDB.
Limitations
Section titled “Limitations”- No roles or permissions in
Identity. Deliberate; use policies. - Narrowing needs the route’s schema. Inside a route declaring
auth: trueor a policy name,ctx.useris narrowed to non-null — delete the guard and the code stops compiling. On a route with no guard it staysIdentity | null, correctly, because an anonymous request can reach it. In a plain middleware, where there is no schema to read, userequireUser(ctx.user).