Skip to content

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

Pair it with a provider — auth-basic for email and password, or a hosted one.

Terminal window
bun add @theoven/auth @theoven/auth-basic
src/app.ts
import { 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 }))

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

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.

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.

Nothing. No files, no tables. Storage bricks own their tables and list them on their own pages.

app.get('/me', { auth: true }, (ctx) => ctx.user) // 401 when anonymous
app.get('/admin', { auth: 'admin' }, (ctx) => ...) // 403 unless the policy passes
app.get('/public', (ctx) => ctx.user?.id ?? 'anonymous') // open, user may still be present

The 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.

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).

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.

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.

Since roles are not normalised, authorization is a named function you write:

src/policies.ts
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 pass

A 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:

src/policies.test.ts
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.

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:

src/policies.ts
// 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)),
}
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,

Google and GitHub are built into the password providers — auth-basic and auth-mongo — and off until configured.

src/app.ts
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

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.

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).

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.

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.

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:

src/routes/me.get.ts
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 })
src/routes/users/[userId]/billing.get.ts
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-clerk
await ctx.auth.signOut?.(ctx)

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.

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:

src/routes/register.post.ts
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

Signing in with a provider links it as a side effect. Detaching one is a call you make:

src/routes/settings/unlink.post.ts
import { unlinkAccount } from '@theoven/auth'
export const auth = true
export 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.

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.

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.

@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.

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.

  • No roles or permissions in Identity. Deliberate; use policies.
  • Narrowing needs the route’s schema. Inside a route declaring auth: true or a policy name, ctx.user is narrowed to non-null — delete the guard and the code stops compiling. On a route with no guard it stays Identity | null, correctly, because an anonymous request can reach it. In a plain middleware, where there is no schema to read, use requireUser(ctx.user).