auth-mongo
| Package | @theoven/auth-mongo |
| Adds to context | ctx.user, ctx.auth — it is a provider for auth |
| Endpoints | eight, at /auth/* |
| Creates files | none |
| Creates collections | auth_users, auth_refresh_tokens, auth_reset_tokens |
| Status | shipped |
Install
Section titled “Install”bun add @theoven/auth @theoven/auth-mongo mongooseimport { createApp, env } from '@theoven/core'import { auth } from '@theoven/auth'import { mongoAuth } from '@theoven/auth-mongo'import { db } from '@theoven/db'import { mongooseDb } from '@theoven/db-mongoose'import { createConnection } from 'mongoose'
const connection = await createConnection(env.string('MONGO_URL')).asPromise()
export const app = createApp() .use(db(mongooseDb({ url: env.string('MONGO_URL') }))) .use(auth(mongoAuth({ connection, secret: env.string('AUTH_SECRET') })))What it does
Section titled “What it does”Email-and-password authentication with users, sessions and password resets stored in MongoDB.
It mounts the whole flow at /auth/*, hashes passwords with argon2id, issues a short-lived access
JWT plus a revocable refresh token, and identifies every request from that JWT.
It is auth-basic with Mongoose underneath — literally, not
approximately. See below.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose | Auth |
|---|---|---|---|
POST |
/auth/signup |
create an account | none |
POST |
/auth/login |
sign in | none |
POST |
/auth/refresh |
new access token from the refresh cookie | cookie |
POST |
/auth/logout |
revoke the session | cookie |
POST |
/auth/forgot-password |
email a reset link | none |
POST |
/auth/reset-password |
redeem a reset token | token |
POST |
/auth/change-password |
change while signed in | bearer |
GET |
/auth/me |
the current user | bearer |
The prefix is configurable: auth(mongoAuth(...), { prefix: '/identity' }). A test asserts this
list matches auth-basic’s exactly — that is what “different storage, same brick” has to mean to
be worth saying.
It is auth-basic with different storage
Section titled “It is auth-basic with different storage”That is the whole description, and it is meant literally. The same eight endpoints, the same
tokens, the same cookie, the same rate limits, the same refusals — a test asserts the endpoint
list matches auth-basic’s exactly.
So everything on the auth-basic page applies here: the
endpoints, the
configuration, the
rate limits, the
security decisions. Only two options differ:
| Option | auth-basic |
auth-mongo |
|---|---|---|
| storage | db — a Drizzle client |
connection — a Mongoose Connection |
What it creates
Section titled “What it creates”Three collections, created on first write:
| Collection | Holds |
|---|---|
auth_users |
id, email, name, argon2id hash, emailVerifiedAt, createdAt |
auth_refresh_tokens |
id, user, SHA-256 of the token, expiry |
auth_reset_tokens |
id, user, SHA-256 of the token, expiry, usedAt |
Neither token is stored in the clear. A leaked database should not hand over working credentials.
Indexes are built when the models compile — unique on auth_users.email and on both token
hashes, plus auth_refresh_tokens.userId, which is what “sign out everywhere” deletes by.
_id is your id, not an ObjectId
Section titled “_id is your id, not an ObjectId”The ids are generated by @theoven/auth, so a user id has the same shape whichever storage
brick you chose. Letting Mongo assign an ObjectId instead would make the id format depend on
that choice — and those ids are visible in JWT subjects, in URLs, and in anything that stored
one.
Configuration
Section titled “Configuration”mongoAuth({ connection, secret: env.string('AUTH_SECRET'), accessTtl: 15 * 60, refreshTtl: 30 * 24 * 60 * 60, minPasswordLength: 12, sendResetEmail: (to, token) => mail.send({ to, subject: 'Reset', text: resetUrl(token) }),})| Option | Default | |
|---|---|---|
connection |
— | required; a Mongoose Connection, not the global singleton |
secret |
— | required; signs access tokens. No default exists, deliberately |
accessTtl |
900 |
access-token lifetime, seconds |
refreshTtl |
2592000 |
refresh-token lifetime, seconds (30 days) |
resetTtl |
3600 |
reset-link lifetime, seconds |
minPasswordLength |
8 |
|
sendResetEmail |
— | without it the reset link is logged, and boot warns |
rateLimit |
on | { login: 10, signup: 5, forgotPassword: 3, window: 900000 }, or false |
refreshCookie |
oven_refresh |
name of the httpOnly refresh cookie |
Every one of these is the shared option from @theoven/auth, so the
auth-basic configuration notes apply unchanged.
The endpoints are mounted for you; your own routes just read ctx.user:
export const auth = true
export default async ({ user }) => ({ id: user.id, email: user.email, name: user.name })user.raw is the stored user document, so anything you added to the collection is reachable.
It is the whole document, passwordHash included — fine for a policy, not something to return from a handler.
Calling the flows yourself
Section titled “Calling the flows yourself”When you want your own endpoint — a signup that also creates a workspace, say — use the flows rather than reimplementing them:
import { signup } from '@theoven/auth'import { z } from 'zod'
export const body = z.object({ email: z.email(), password: z.string().min(8), name: z.string(), workspace: z.string(),})
export default async ({ body, auth, db }) => { // `signup` returns the identity and the token pair, not one merged object. const { user, tokens } = await signup(auth.flows, body) await db.model('Workspace', workspaceSchema).create({ name: body.workspace, owner: user.id }) return { user, ...tokens }}ctx.auth.flows is the shared FlowConfig — the same one the mounted endpoints use, so password
rules, hashing and token lifetimes stay identical rather than being re-specified here.
Capabilities
Section titled “Capabilities”identify |
✓ — from the access JWT |
routes |
✓ — the eight endpoints above |
signOut |
✓ — /auth/logout revokes the refresh row |
refresh |
✓ — /auth/refresh rotates it |
What fails at boot: a missing secret, and a missing connection. A missing sendResetEmail
warns rather than fails, so reset works in development before mail is configured.
Housekeeping
Section titled “Housekeeping”import { pruneExpiredTokens } from '@theoven/auth-mongo'
await pruneExpiredTokens(connection)Nothing calls this. Expired tokens are already refused on use, so it is housekeeping rather than a security control — run it from a cron job when the collections start to bother you.
The contract this tests
Section titled “The contract this tests”AuthStore is seven methods, and until this package it had one implementation. A contract with
one implementation is a guess.
It fit without changes. The conformance suite lives in @theoven/auth/testing and both bricks
run the same one:
import { describeAuthStore } from '@theoven/auth/testing'
describeAuthStore('my storage', () => myStore(connection))If you write a storage brick, run it. It checks the behaviours the flows actually depend on —
case-insensitive email lookup, tokens found by hash, usedAt persisting, and the one that
matters most: that deleteRefreshTokens({}) deletes nothing rather than signing out every
user in the database.
How it is verified
Section titled “How it is verified”Against a real MongoDB, not a fake — a fake store would only prove the fake works, and the
things that go wrong here are Mongo’s own: unique index violations, case-insensitive lookup,
usedAt persisting.
It runs the shared describeAuthStore conformance suite from @theoven/auth/testing — the same
suite auth-basic runs — plus the full flow end to end through a real app: signup, login, refresh,
logout, password change, reset.
The test that matters most is the one asserting deleteRefreshTokens({}) deletes nothing. In
Mongo an empty filter matches every document, so the naive implementation of “sign out this user”
signs out the entire database. That is a Mongo-specific footgun auth-basic cannot have, and it is
exactly why the conformance suite exists.
Limitations
Section titled “Limitations”- Requires a running MongoDB. There is no in-memory mode; a fake would only prove the fake
works. The integration tests are gated on
MONGO_URLand run in CI againstmongo:8. - Mongoose 8, not 9 — see
db-mongoosefor why. - No email verification.
emailVerifiedAtexists and is never set, exactly as inauth-basic. - Rate limiting is per process, as in
auth-basic. - No OAuth. Use
auth-clerkorauth-better— both ship, and both satisfy the same contract, soauth: trueand your policies keep working.